what-server 0.5.5 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,628 @@
1
+ // packages/server/src/index.js
2
+ import { h } from "what-core";
3
+
4
+ // packages/server/src/actions.js
5
+ import { signal, batch } from "what-core";
6
+ var actionRegistry = /* @__PURE__ */ new Map();
7
+ function getCsrfToken() {
8
+ if (typeof document !== "undefined") {
9
+ const meta = document.querySelector('meta[name="what-csrf-token"]');
10
+ if (meta) {
11
+ return meta.getAttribute("content");
12
+ }
13
+ const match = document.cookie.match(/(?:^|;\s*)what-csrf=([^;]+)/);
14
+ if (match) {
15
+ return decodeURIComponent(match[1]);
16
+ }
17
+ }
18
+ return null;
19
+ }
20
+ function generateCsrfToken() {
21
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
22
+ return crypto.randomUUID();
23
+ }
24
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
25
+ const arr = new Uint8Array(16);
26
+ crypto.getRandomValues(arr);
27
+ return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
28
+ }
29
+ throw new Error("[what] No secure random source available for CSRF token generation");
30
+ }
31
+ function validateCsrfToken(requestToken, sessionToken) {
32
+ if (!requestToken || !sessionToken) return false;
33
+ if (requestToken.length !== sessionToken.length) return false;
34
+ let result = 0;
35
+ for (let i = 0; i < requestToken.length; i++) {
36
+ result |= requestToken.charCodeAt(i) ^ sessionToken.charCodeAt(i);
37
+ }
38
+ return result === 0;
39
+ }
40
+ function csrfMetaTag(token) {
41
+ const escaped = String(token).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
42
+ return `<meta name="what-csrf-token" content="${escaped}">`;
43
+ }
44
+ var _actionCounter = 0;
45
+ function generateActionId() {
46
+ const rand = typeof crypto !== "undefined" && crypto.getRandomValues ? Array.from(crypto.getRandomValues(new Uint8Array(6)), (b) => b.toString(16).padStart(2, "0")).join("") : `c${(++_actionCounter).toString(36)}_${Date.now().toString(36)}`;
47
+ return `a_${rand}`;
48
+ }
49
+ function action(fn, options = {}) {
50
+ const id = options.id || generateActionId();
51
+ const { onError, onSuccess, revalidate } = options;
52
+ if (typeof window === "undefined") {
53
+ actionRegistry.set(id, { fn, options });
54
+ }
55
+ async function callAction(...args) {
56
+ if (typeof window === "undefined") {
57
+ return fn(...args);
58
+ }
59
+ const timeout = options.timeout || 3e4;
60
+ const controller = new AbortController();
61
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
62
+ try {
63
+ const csrfToken = getCsrfToken();
64
+ const headers = {
65
+ "Content-Type": "application/json",
66
+ "X-What-Action": id
67
+ };
68
+ if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
69
+ const response = await fetch("/__what_action", {
70
+ method: "POST",
71
+ headers,
72
+ credentials: "same-origin",
73
+ signal: controller.signal,
74
+ body: JSON.stringify({ args })
75
+ });
76
+ if (!response.ok) {
77
+ const error = await response.json().catch(() => ({ message: "Action failed" }));
78
+ throw new Error(error.message || "Action failed");
79
+ }
80
+ const result = await response.json();
81
+ if (onSuccess) onSuccess(result);
82
+ if (revalidate) {
83
+ for (const path of revalidate) {
84
+ invalidatePath(path);
85
+ }
86
+ }
87
+ return result;
88
+ } catch (error) {
89
+ if (error.name === "AbortError") {
90
+ const timeoutError = new Error(`Action "${id}" timed out after ${timeout}ms`);
91
+ timeoutError.code = "TIMEOUT";
92
+ if (onError) onError(timeoutError);
93
+ throw timeoutError;
94
+ }
95
+ if (onError) onError(error);
96
+ throw error;
97
+ } finally {
98
+ clearTimeout(timeoutId);
99
+ }
100
+ }
101
+ callAction._actionId = id;
102
+ callAction._isAction = true;
103
+ return callAction;
104
+ }
105
+ function formAction(actionFn, options = {}) {
106
+ const { onSuccess, onError, resetOnSuccess = true } = options;
107
+ return async (formDataOrEvent) => {
108
+ let formData;
109
+ let form;
110
+ if (formDataOrEvent instanceof Event) {
111
+ formDataOrEvent.preventDefault();
112
+ form = formDataOrEvent.target;
113
+ formData = new FormData(form);
114
+ } else {
115
+ formData = formDataOrEvent;
116
+ }
117
+ const data = {};
118
+ let hasFiles = false;
119
+ for (const [key, value] of formData.entries()) {
120
+ if (typeof File !== "undefined" && value instanceof File) {
121
+ hasFiles = true;
122
+ }
123
+ if (data[key]) {
124
+ if (Array.isArray(data[key])) {
125
+ data[key].push(value);
126
+ } else {
127
+ data[key] = [data[key], value];
128
+ }
129
+ } else {
130
+ data[key] = value;
131
+ }
132
+ }
133
+ try {
134
+ const result = hasFiles ? await actionFn(data, formData) : await actionFn(data);
135
+ if (onSuccess) onSuccess(result, form);
136
+ if (resetOnSuccess && form) form.reset();
137
+ return result;
138
+ } catch (error) {
139
+ if (onError) onError(error, form);
140
+ throw error;
141
+ }
142
+ };
143
+ }
144
+ function useAction(actionFn) {
145
+ const isPending = signal(false);
146
+ const error = signal(null);
147
+ const data = signal(null);
148
+ async function trigger(...args) {
149
+ isPending.set(true);
150
+ error.set(null);
151
+ try {
152
+ const result = await actionFn(...args);
153
+ data.set(result);
154
+ return result;
155
+ } catch (e) {
156
+ error.set(e);
157
+ throw e;
158
+ } finally {
159
+ isPending.set(false);
160
+ }
161
+ }
162
+ return {
163
+ trigger,
164
+ isPending: () => isPending(),
165
+ error: () => error(),
166
+ data: () => data(),
167
+ reset: () => {
168
+ error.set(null);
169
+ data.set(null);
170
+ }
171
+ };
172
+ }
173
+ function useFormAction(actionFn, options = {}) {
174
+ const { resetOnSuccess = true } = options;
175
+ const formRef = { current: null };
176
+ const actionState = useAction(formAction(actionFn, { resetOnSuccess }));
177
+ function handleSubmit(e) {
178
+ e.preventDefault();
179
+ const formData = new FormData(e.target);
180
+ formRef.current = e.target;
181
+ return actionState.trigger(formData);
182
+ }
183
+ return {
184
+ ...actionState,
185
+ handleSubmit,
186
+ formRef
187
+ };
188
+ }
189
+ function useOptimistic(initialValue, reducer) {
190
+ const value = signal(initialValue);
191
+ const pending = signal([]);
192
+ const baseValue = signal(initialValue);
193
+ function addOptimistic(action2) {
194
+ const optimisticValue = reducer(value.peek(), action2);
195
+ batch(() => {
196
+ pending.set([...pending.peek(), action2]);
197
+ value.set(optimisticValue);
198
+ });
199
+ }
200
+ function resolve(action2, serverValue) {
201
+ batch(() => {
202
+ pending.set(pending.peek().filter((a) => a !== action2));
203
+ if (serverValue !== void 0) {
204
+ baseValue.set(serverValue);
205
+ let current = serverValue;
206
+ for (const a of pending.peek()) {
207
+ current = reducer(current, a);
208
+ }
209
+ value.set(current);
210
+ }
211
+ });
212
+ }
213
+ function rollback(action2, realValue) {
214
+ batch(() => {
215
+ const newPending = pending.peek().filter((a) => a !== action2);
216
+ pending.set(newPending);
217
+ const base = realValue !== void 0 ? realValue : baseValue.peek();
218
+ baseValue.set(base);
219
+ let current = base;
220
+ for (const a of newPending) {
221
+ current = reducer(current, a);
222
+ }
223
+ value.set(current);
224
+ });
225
+ }
226
+ async function withOptimistic(action2, asyncFn) {
227
+ addOptimistic(action2);
228
+ try {
229
+ const result = await asyncFn();
230
+ resolve(action2, result);
231
+ return result;
232
+ } catch (e) {
233
+ rollback(action2);
234
+ throw e;
235
+ }
236
+ }
237
+ return {
238
+ value: () => value(),
239
+ isPending: () => pending().length > 0,
240
+ addOptimistic,
241
+ resolve,
242
+ rollback,
243
+ withOptimistic,
244
+ set: (v) => {
245
+ value.set(v);
246
+ baseValue.set(v);
247
+ }
248
+ };
249
+ }
250
+ var revalidationCallbacks = /* @__PURE__ */ new Map();
251
+ function onRevalidate(path, callback) {
252
+ if (!revalidationCallbacks.has(path)) {
253
+ revalidationCallbacks.set(path, /* @__PURE__ */ new Set());
254
+ }
255
+ revalidationCallbacks.get(path).add(callback);
256
+ return () => {
257
+ revalidationCallbacks.get(path)?.delete(callback);
258
+ };
259
+ }
260
+ function invalidatePath(path) {
261
+ const callbacks = revalidationCallbacks.get(path);
262
+ if (callbacks) {
263
+ for (const cb of callbacks) {
264
+ try {
265
+ cb();
266
+ } catch (e) {
267
+ console.error("[what] Revalidation error:", e);
268
+ }
269
+ }
270
+ }
271
+ }
272
+ function handleActionRequest(req, actionId, args, options = {}) {
273
+ const { csrfToken: sessionCsrfToken, skipCsrf = false } = options;
274
+ if (!skipCsrf) {
275
+ if (!sessionCsrfToken) {
276
+ return Promise.resolve({
277
+ status: 500,
278
+ body: {
279
+ message: "[what] CSRF token not configured. Pass { csrfToken: sessionToken } to handleActionRequest, or { skipCsrf: true } to explicitly opt out."
280
+ }
281
+ });
282
+ }
283
+ const requestCsrfToken = req?.headers?.["x-csrf-token"] || req?.headers?.["X-CSRF-Token"];
284
+ if (!validateCsrfToken(requestCsrfToken, sessionCsrfToken)) {
285
+ return Promise.resolve({ status: 403, body: { message: "Invalid CSRF token" } });
286
+ }
287
+ }
288
+ const action2 = actionRegistry.get(actionId);
289
+ if (!action2) {
290
+ return Promise.resolve({ status: 404, body: { message: "Action not found" } });
291
+ }
292
+ if (!Array.isArray(args)) {
293
+ return Promise.resolve({ status: 400, body: { message: "Invalid action arguments" } });
294
+ }
295
+ return action2.fn(...args).then((result) => ({ status: 200, body: result })).catch((error) => {
296
+ console.error(`[what] Action "${actionId}" error:`, error);
297
+ return {
298
+ status: 500,
299
+ body: { message: "Action failed" }
300
+ };
301
+ });
302
+ }
303
+ function getRegisteredActions() {
304
+ return [...actionRegistry.keys()];
305
+ }
306
+ function useMutation(mutationFn, options = {}) {
307
+ const { onSuccess, onError, onSettled } = options;
308
+ const state = {
309
+ isPending: signal(false),
310
+ error: signal(null),
311
+ data: signal(null)
312
+ };
313
+ async function mutate(...args) {
314
+ state.isPending.set(true);
315
+ state.error.set(null);
316
+ try {
317
+ const result = await mutationFn(...args);
318
+ state.data.set(result);
319
+ if (onSuccess) onSuccess(result, ...args);
320
+ return result;
321
+ } catch (error) {
322
+ state.error.set(error);
323
+ if (onError) onError(error, ...args);
324
+ throw error;
325
+ } finally {
326
+ state.isPending.set(false);
327
+ if (onSettled) onSettled(state.data.peek(), state.error.peek(), ...args);
328
+ }
329
+ }
330
+ return {
331
+ mutate,
332
+ isPending: () => state.isPending(),
333
+ error: () => state.error(),
334
+ data: () => state.data(),
335
+ reset: () => {
336
+ state.error.set(null);
337
+ state.data.set(null);
338
+ }
339
+ };
340
+ }
341
+
342
+ // packages/server/src/index.js
343
+ var _hydrationIdCounter = 0;
344
+ function resetHydrationId() {
345
+ _hydrationIdCounter = 0;
346
+ }
347
+ function nextHydrationId() {
348
+ return "h" + _hydrationIdCounter++;
349
+ }
350
+ function renderToHydratableString(vnode) {
351
+ resetHydrationId();
352
+ return _renderHydratable(vnode);
353
+ }
354
+ function _renderHydratable(vnode) {
355
+ if (vnode == null || vnode === false || vnode === true) return "";
356
+ if (typeof vnode === "string" || typeof vnode === "number") {
357
+ return escapeHtml(String(vnode));
358
+ }
359
+ if (typeof vnode === "function" && vnode._signal) {
360
+ return `<!--$-->${_renderHydratable(vnode())}<!--/$-->`;
361
+ }
362
+ if (typeof vnode === "function") {
363
+ try {
364
+ return `<!--$-->${_renderHydratable(vnode())}<!--/$-->`;
365
+ } catch (e) {
366
+ if (typeof process !== "undefined" && true) {
367
+ console.warn("[what-server] Error rendering reactive function in SSR:", e.message);
368
+ }
369
+ return "<!--$--><!--/$-->";
370
+ }
371
+ }
372
+ if (Array.isArray(vnode)) {
373
+ return `<!--[]-->${vnode.map(_renderHydratable).join("")}<!--/[]-->`;
374
+ }
375
+ if (typeof vnode.tag === "function") {
376
+ const hkId = nextHydrationId();
377
+ const result = vnode.tag({ ...vnode.props, children: vnode.children });
378
+ const html = _renderHydratable(result);
379
+ return injectHydrationKey(html, hkId);
380
+ }
381
+ const { tag, props, children } = vnode;
382
+ const attrs = renderAttrs(props || {});
383
+ const open = `<${tag}${attrs}>`;
384
+ if (VOID_ELEMENTS.has(tag)) return open;
385
+ const rawInner = _resolveInnerHTML(props);
386
+ const inner = rawInner != null ? String(rawInner) : children.map(_renderHydratable).join("");
387
+ return `${open}${inner}</${tag}>`;
388
+ }
389
+ function injectHydrationKey(html, hkId) {
390
+ const match = html.match(/^((?:<!--.*?-->)*)<([a-zA-Z][a-zA-Z0-9-]*)/);
391
+ if (match) {
392
+ const prefix = match[1];
393
+ const tagName = match[2];
394
+ const insertAt = prefix.length + 1 + tagName.length;
395
+ return html.slice(0, insertAt) + ` data-hk="${hkId}"` + html.slice(insertAt);
396
+ }
397
+ return html;
398
+ }
399
+ function renderToString(vnode) {
400
+ if (vnode == null || vnode === false || vnode === true) return "";
401
+ if (typeof vnode === "string" || typeof vnode === "number") {
402
+ return escapeHtml(String(vnode));
403
+ }
404
+ if (typeof vnode === "function" && vnode._signal) {
405
+ return renderToString(vnode());
406
+ }
407
+ if (typeof vnode === "function") {
408
+ try {
409
+ return renderToString(vnode());
410
+ } catch (e) {
411
+ if (typeof process !== "undefined" && true) {
412
+ console.warn("[what-server] Error rendering reactive function in SSR:", e.message);
413
+ }
414
+ return "";
415
+ }
416
+ }
417
+ if (Array.isArray(vnode)) {
418
+ return vnode.map(renderToString).join("");
419
+ }
420
+ if (typeof vnode.tag === "function") {
421
+ const result = vnode.tag({ ...vnode.props, children: vnode.children });
422
+ return renderToString(result);
423
+ }
424
+ const { tag, props, children } = vnode;
425
+ const attrs = renderAttrs(props || {});
426
+ const open = `<${tag}${attrs}>`;
427
+ if (VOID_ELEMENTS.has(tag)) return open;
428
+ const rawInner = _resolveInnerHTML(props);
429
+ const inner = rawInner != null ? String(rawInner) : children.map(renderToString).join("");
430
+ return `${open}${inner}</${tag}>`;
431
+ }
432
+ async function* renderToStream(vnode) {
433
+ if (vnode == null || vnode === false || vnode === true) return;
434
+ if (typeof vnode === "string" || typeof vnode === "number") {
435
+ yield escapeHtml(String(vnode));
436
+ return;
437
+ }
438
+ if (typeof vnode === "function" && vnode._signal) {
439
+ yield* renderToStream(vnode());
440
+ return;
441
+ }
442
+ if (typeof vnode === "function") {
443
+ try {
444
+ yield* renderToStream(vnode());
445
+ } catch (e) {
446
+ if (typeof process !== "undefined" && true) {
447
+ console.warn("[what-server] Error rendering reactive function in stream SSR:", e.message);
448
+ }
449
+ }
450
+ return;
451
+ }
452
+ if (Array.isArray(vnode)) {
453
+ for (const child of vnode) {
454
+ yield* renderToStream(child);
455
+ }
456
+ return;
457
+ }
458
+ if (typeof vnode.tag === "function") {
459
+ try {
460
+ const result = vnode.tag({ ...vnode.props, children: vnode.children });
461
+ const resolved = result instanceof Promise ? await result : result;
462
+ yield* renderToStream(resolved);
463
+ } catch (e) {
464
+ if (typeof process !== "undefined" && true) {
465
+ console.warn("[what-server] Error rendering component in stream SSR:", e.message);
466
+ }
467
+ yield _isDevMode ? `<!-- SSR Error: ${escapeHtml(e.message || "Component error")} -->` : `<!-- SSR Error -->`;
468
+ }
469
+ return;
470
+ }
471
+ const { tag, props, children } = vnode;
472
+ const attrs = renderAttrs(props || {});
473
+ yield `<${tag}${attrs}>`;
474
+ if (!VOID_ELEMENTS.has(tag)) {
475
+ const rawInner = _resolveInnerHTML(props);
476
+ if (rawInner != null) {
477
+ yield String(rawInner);
478
+ } else {
479
+ for (const child of children) {
480
+ yield* renderToStream(child);
481
+ }
482
+ }
483
+ yield `</${tag}>`;
484
+ }
485
+ }
486
+ function definePage(config) {
487
+ return {
488
+ // 'static' = pre-render at build time (default)
489
+ // 'server' = render on each request
490
+ // 'client' = render in browser (SPA)
491
+ // 'hybrid' = static shell + islands
492
+ mode: "static",
493
+ ...config
494
+ };
495
+ }
496
+ function generateStaticPage(page, data = {}) {
497
+ const vnode = page.component(data);
498
+ const html = renderToString(vnode);
499
+ const islands = page.islands || [];
500
+ return wrapDocument({
501
+ title: page.title || "",
502
+ meta: page.meta || {},
503
+ body: html,
504
+ islands,
505
+ scripts: page.mode === "static" ? [] : page.scripts || [],
506
+ styles: page.styles || [],
507
+ mode: page.mode
508
+ });
509
+ }
510
+ function wrapDocument({ title, meta, body, islands, scripts, styles, mode }) {
511
+ const metaTags = Object.entries(meta).map(([name, content]) => `<meta name="${escapeHtml(name)}" content="${escapeHtml(content)}">`).join("\n ");
512
+ const styleTags = styles.map((href) => `<link rel="stylesheet" href="${escapeHtml(href)}">`).join("\n ");
513
+ const islandScript = islands.length > 0 ? `
514
+ <script type="module">
515
+ import { hydrateIslands } from '/@what/islands.js';
516
+ hydrateIslands();
517
+ <\/script>` : "";
518
+ const scriptTags = scripts.map((src) => `<script type="module" src="${escapeHtml(src)}"><\/script>`).join("\n ");
519
+ const clientScript = mode === "client" ? `
520
+ <script type="module" src="/@what/client.js"><\/script>` : "";
521
+ return `<!DOCTYPE html>
522
+ <html lang="en">
523
+ <head>
524
+ <meta charset="UTF-8">
525
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
526
+ ${metaTags}
527
+ <title>${escapeHtml(title)}</title>
528
+ ${styleTags}
529
+ </head>
530
+ <body>
531
+ <div id="app">${body}</div>
532
+ ${islandScript}
533
+ ${scriptTags}
534
+ ${clientScript}
535
+ </body>
536
+ </html>`;
537
+ }
538
+ function server(Component) {
539
+ Component._server = true;
540
+ return Component;
541
+ }
542
+ var _isDevMode = typeof process !== "undefined" ? true : true;
543
+ function _resolveInnerHTML(props) {
544
+ if (!props) return null;
545
+ if (props.dangerouslySetInnerHTML) {
546
+ return props.dangerouslySetInnerHTML.__html ?? null;
547
+ }
548
+ if (props.innerHTML && typeof props.innerHTML === "object" && "__html" in props.innerHTML) {
549
+ return props.innerHTML.__html ?? null;
550
+ }
551
+ if (props.innerHTML != null && typeof props.innerHTML === "string") {
552
+ if (_isDevMode) {
553
+ console.warn(
554
+ "[what-server] innerHTML received a raw string. This is a security risk (XSS). Use innerHTML={{ __html: trustedString }} or dangerouslySetInnerHTML={{ __html: trustedString }} instead."
555
+ );
556
+ }
557
+ return null;
558
+ }
559
+ return null;
560
+ }
561
+ function renderAttrs(props) {
562
+ let out = "";
563
+ for (const [key, val] of Object.entries(props)) {
564
+ if (key === "key" || key === "ref" || key === "children" || key === "dangerouslySetInnerHTML" || key === "innerHTML") continue;
565
+ if (key.startsWith("on") && key.length > 2) continue;
566
+ if (val === false || val == null) continue;
567
+ if (key === "className" || key === "class") {
568
+ out += ` class="${escapeHtml(String(val))}"`;
569
+ } else if (key === "style" && typeof val === "object") {
570
+ const css = Object.entries(val).map(([p, v]) => `${camelToKebab(p)}:${v}`).join(";");
571
+ out += ` style="${escapeHtml(css)}"`;
572
+ } else if (val === true) {
573
+ if (key.startsWith("aria-") || key === "role") {
574
+ out += ` ${key}="true"`;
575
+ } else {
576
+ out += ` ${key}`;
577
+ }
578
+ } else {
579
+ out += ` ${key}="${escapeHtml(String(val))}"`;
580
+ }
581
+ }
582
+ return out;
583
+ }
584
+ function escapeHtml(str) {
585
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
586
+ }
587
+ function camelToKebab(str) {
588
+ if (str.startsWith("--")) return str;
589
+ return str.replace(/([A-Z])/g, "-$1").toLowerCase();
590
+ }
591
+ var VOID_ELEMENTS = /* @__PURE__ */ new Set([
592
+ "area",
593
+ "base",
594
+ "br",
595
+ "col",
596
+ "embed",
597
+ "hr",
598
+ "img",
599
+ "input",
600
+ "link",
601
+ "meta",
602
+ "param",
603
+ "source",
604
+ "track",
605
+ "wbr"
606
+ ]);
607
+ export {
608
+ action,
609
+ csrfMetaTag,
610
+ definePage,
611
+ formAction,
612
+ generateCsrfToken,
613
+ generateStaticPage,
614
+ getRegisteredActions,
615
+ handleActionRequest,
616
+ invalidatePath,
617
+ onRevalidate,
618
+ renderToHydratableString,
619
+ renderToStream,
620
+ renderToString,
621
+ server,
622
+ useAction,
623
+ useFormAction,
624
+ useMutation,
625
+ useOptimistic,
626
+ validateCsrfToken
627
+ };
628
+ //# sourceMappingURL=index.js.map