@ozsarman/clarityjs 0.6.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 +178 -0
- package/package.json +168 -0
- package/src/analyze.js +534 -0
- package/src/async-state.js +555 -0
- package/src/bundle-runtime.js +35 -0
- package/src/clarity-bundle.js +332 -0
- package/src/clarity-test.js +622 -0
- package/src/cli.js +453 -0
- package/src/codegen.js +1934 -0
- package/src/dev-server.js +362 -0
- package/src/devtools.js +765 -0
- package/src/edge.js +606 -0
- package/src/error-overlay.js +535 -0
- package/src/file-conventions.js +472 -0
- package/src/font.js +513 -0
- package/src/game-loop.js +106 -0
- package/src/head.js +393 -0
- package/src/hydrate.js +292 -0
- package/src/i18n.js +403 -0
- package/src/image.js +352 -0
- package/src/index.js +193 -0
- package/src/islands.js +284 -0
- package/src/isr.js +306 -0
- package/src/layout.js +342 -0
- package/src/lexer.js +572 -0
- package/src/linter.js +547 -0
- package/src/pages-router.js +229 -0
- package/src/parser.js +1108 -0
- package/src/router.js +732 -0
- package/src/runtime.js +1465 -0
- package/src/scoped-css.js +641 -0
- package/src/server-actions.js +439 -0
- package/src/server-data.js +225 -0
- package/src/sourcemap.js +130 -0
- package/src/ssg.js +310 -0
- package/src/ssr.js +621 -0
- package/src/store.js +276 -0
- package/src/transitions.js +438 -0
- package/src/ts-plugin.js +613 -0
- package/src/typegen.js +240 -0
- package/src/vite-plugin.js +447 -0
- package/types/index.d.ts +366 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clarity.js — Server Actions
|
|
3
|
+
*
|
|
4
|
+
* Type-safe client→server RPC over fetch.
|
|
5
|
+
* Similar to Next.js "use server" / React Server Actions, but framework-agnostic:
|
|
6
|
+
* any Node.js / edge runtime can host the action endpoint.
|
|
7
|
+
*
|
|
8
|
+
* ─── Server side (Node / Bun / Deno / edge) ──────────────────────────────────
|
|
9
|
+
*
|
|
10
|
+
* // actions/user.js — mark with "use server" or defineServerAction()
|
|
11
|
+
* import { defineServerAction } from '@ozsarman/clarityjs/server-actions';
|
|
12
|
+
*
|
|
13
|
+
* export const createUser = defineServerAction('user/create', async ({ name, email }) => {
|
|
14
|
+
* const user = await db.users.create({ name, email });
|
|
15
|
+
* return { user };
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* export const deleteUser = defineServerAction('user/delete', async ({ id }) => {
|
|
19
|
+
* await db.users.delete(id);
|
|
20
|
+
* return { ok: true };
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* ─── Server router integration ────────────────────────────────────────────────
|
|
24
|
+
*
|
|
25
|
+
* import { createActionRouter } from '@ozsarman/clarityjs/server-actions';
|
|
26
|
+
* import * as userActions from './actions/user.js';
|
|
27
|
+
*
|
|
28
|
+
* const router = createActionRouter({ ...userActions });
|
|
29
|
+
*
|
|
30
|
+
* // Express:
|
|
31
|
+
* app.post('/_clarity/actions/:name', router.express());
|
|
32
|
+
*
|
|
33
|
+
* // Fetch handler (edge / Bun / Deno):
|
|
34
|
+
* export default { fetch: router.fetch() };
|
|
35
|
+
*
|
|
36
|
+
* ─── Client side (.clarity component) ───────────────────────────────────────
|
|
37
|
+
*
|
|
38
|
+
* import { createServerClient, useServerAction } from '@ozsarman/clarityjs/server-actions';
|
|
39
|
+
*
|
|
40
|
+
* // Create once at app root (reads CLARITY_SERVER_URL env or window.__CLARITY_SERVER__)
|
|
41
|
+
* const server = createServerClient();
|
|
42
|
+
*
|
|
43
|
+
* // In a component:
|
|
44
|
+
* const { execute, loading, error, data } = useServerAction(server, 'user/create');
|
|
45
|
+
*
|
|
46
|
+
* // Call it:
|
|
47
|
+
* await execute({ name: 'Özdemir', email: 'oz@example.com' });
|
|
48
|
+
* // data.get() → { user: { id: 1, name: 'Özdemir', ... } }
|
|
49
|
+
*
|
|
50
|
+
* ─── Optimistic updates ───────────────────────────────────────────────────────
|
|
51
|
+
*
|
|
52
|
+
* const { execute } = useServerAction(server, 'todo/add', {
|
|
53
|
+
* optimistic: (args, currentData) => [...currentData, { text: args.text, pending: true }],
|
|
54
|
+
* });
|
|
55
|
+
*
|
|
56
|
+
* Author: Claude (Anthropic) + Özdemir Sarman
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
import { signal } from './runtime.js';
|
|
60
|
+
|
|
61
|
+
// ─── Server-side: action registry ────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
const _actionRegistry = new Map(); // name → handler fn
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Define a server action — registers it in the global registry with a unique name.
|
|
67
|
+
*
|
|
68
|
+
* Returns the original handler so it can also be called locally (server-to-server).
|
|
69
|
+
*
|
|
70
|
+
* @template I, O
|
|
71
|
+
* @param {string} name — unique action identifier (e.g. 'user/create')
|
|
72
|
+
* @param {(input: I) => Promise<O>} handler — async server function
|
|
73
|
+
* @returns {(input: I) => Promise<O>} — the handler itself (callable directly on server)
|
|
74
|
+
*/
|
|
75
|
+
export function defineServerAction(name, handler) {
|
|
76
|
+
if (typeof name !== 'string' || !name.trim()) {
|
|
77
|
+
throw new TypeError('[Server Actions] name must be a non-empty string');
|
|
78
|
+
}
|
|
79
|
+
if (typeof handler !== 'function') {
|
|
80
|
+
throw new TypeError(`[Server Actions] handler for "${name}" must be a function`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_actionRegistry.set(name, handler);
|
|
84
|
+
|
|
85
|
+
// Attach metadata so createActionRouter() can discover it
|
|
86
|
+
const fn = async (input) => handler(input);
|
|
87
|
+
fn.__serverAction__ = name;
|
|
88
|
+
fn.__handler__ = handler;
|
|
89
|
+
return fn;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* List all registered server actions.
|
|
94
|
+
* @returns {string[]}
|
|
95
|
+
*/
|
|
96
|
+
export function listServerActions() {
|
|
97
|
+
return [..._actionRegistry.keys()];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Server-side: action router ───────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Create an HTTP router that dispatches POST requests to registered actions.
|
|
104
|
+
*
|
|
105
|
+
* Endpoint convention: POST /_clarity/actions/:name
|
|
106
|
+
* Request body: JSON { input: {...} }
|
|
107
|
+
* Response body: JSON { data: {...} } or { error: 'message' }
|
|
108
|
+
*
|
|
109
|
+
* @param {...object} actionModules — spread of { [fnName]: serverActionFn } exports
|
|
110
|
+
* @returns {{ express(): Function, fetch(): Function, handle(name, input): Promise }}
|
|
111
|
+
*/
|
|
112
|
+
export function createActionRouter(...actionModules) {
|
|
113
|
+
// Register all passed actions
|
|
114
|
+
for (const mod of actionModules) {
|
|
115
|
+
for (const [, fn] of Object.entries(mod ?? {})) {
|
|
116
|
+
if (fn?.__serverAction__) {
|
|
117
|
+
_actionRegistry.set(fn.__serverAction__, fn.__handler__);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Dispatch an action by name with given input.
|
|
124
|
+
* @param {string} name
|
|
125
|
+
* @param {*} input
|
|
126
|
+
*/
|
|
127
|
+
async function handle(name, input) {
|
|
128
|
+
const handler = _actionRegistry.get(name);
|
|
129
|
+
if (!handler) {
|
|
130
|
+
const err = new Error(`Unknown server action: "${name}"`);
|
|
131
|
+
err.status = 404;
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
return handler(input);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Express/Fastify-compatible middleware.
|
|
139
|
+
* Mount at: app.post('/_clarity/actions/:name', router.express())
|
|
140
|
+
*/
|
|
141
|
+
function express() {
|
|
142
|
+
return async function clarityActionMiddleware(req, res) {
|
|
143
|
+
const name = req.params?.name ?? req.url?.split('/').pop();
|
|
144
|
+
let input;
|
|
145
|
+
try {
|
|
146
|
+
input = req.body?.input ?? req.body ?? {};
|
|
147
|
+
} catch {
|
|
148
|
+
input = {};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const data = await handle(name, input);
|
|
153
|
+
res.json({ data });
|
|
154
|
+
} catch (err) {
|
|
155
|
+
const status = err.status ?? 500;
|
|
156
|
+
res.status(status).json({ error: err.message ?? 'Internal server error' });
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Fetch-based handler — works in Bun, Deno, Cloudflare Workers, Vercel Edge.
|
|
163
|
+
* Mount as: export default { fetch: router.fetch() }
|
|
164
|
+
*
|
|
165
|
+
* Matches: POST /_clarity/actions/:name
|
|
166
|
+
*/
|
|
167
|
+
function fetch() {
|
|
168
|
+
return async function clarityActionFetch(request) {
|
|
169
|
+
const url = new URL(request.url);
|
|
170
|
+
|
|
171
|
+
if (request.method !== 'POST') {
|
|
172
|
+
return new Response('Method Not Allowed', { status: 405 });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const prefix = '/_clarity/actions/';
|
|
176
|
+
if (!url.pathname.startsWith(prefix)) {
|
|
177
|
+
return new Response('Not Found', { status: 404 });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const name = url.pathname.slice(prefix.length);
|
|
181
|
+
let input = {};
|
|
182
|
+
try {
|
|
183
|
+
const body = await request.json();
|
|
184
|
+
input = body?.input ?? body ?? {};
|
|
185
|
+
} catch { /* empty body */ }
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const data = await handle(name, input);
|
|
189
|
+
return Response.json({ data });
|
|
190
|
+
} catch (err) {
|
|
191
|
+
const status = err.status ?? 500;
|
|
192
|
+
return Response.json({ error: err.message ?? 'Internal server error' }, { status });
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { handle, express, fetch };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─── Client-side: server client ───────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Create a typed client for calling server actions over HTTP.
|
|
204
|
+
*
|
|
205
|
+
* Reads the base URL from (in priority order):
|
|
206
|
+
* 1. opts.baseUrl
|
|
207
|
+
* 2. globalThis.__CLARITY_SERVER__
|
|
208
|
+
* 3. import.meta.env.VITE_CLARITY_SERVER_URL
|
|
209
|
+
* 4. '' (same origin — default for most setups)
|
|
210
|
+
*
|
|
211
|
+
* @param {object} [opts]
|
|
212
|
+
* @param {string} [opts.baseUrl] — e.g. 'https://api.mysite.com'
|
|
213
|
+
* @param {string} [opts.prefix] — action endpoint prefix (default: '/_clarity/actions')
|
|
214
|
+
* @param {object} [opts.headers] — extra headers (e.g. auth token)
|
|
215
|
+
* @param {Function}[opts.onError] — global error hook (err) => void
|
|
216
|
+
* @returns {ServerClient}
|
|
217
|
+
*/
|
|
218
|
+
export function createServerClient({
|
|
219
|
+
baseUrl = '',
|
|
220
|
+
prefix = '/_clarity/actions',
|
|
221
|
+
headers = {},
|
|
222
|
+
onError = null,
|
|
223
|
+
} = {}) {
|
|
224
|
+
// Auto-detect base URL
|
|
225
|
+
const _base = baseUrl ||
|
|
226
|
+
(typeof globalThis !== 'undefined' ? globalThis.__CLARITY_SERVER__ : '') ||
|
|
227
|
+
(typeof import.meta !== 'undefined' ? (import.meta.env?.VITE_CLARITY_SERVER_URL ?? '') : '') ||
|
|
228
|
+
'';
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Call a server action by name.
|
|
232
|
+
*
|
|
233
|
+
* @param {string} name — action name (e.g. 'user/create')
|
|
234
|
+
* @param {*} input — JSON-serializable input
|
|
235
|
+
* @returns {Promise<*>} — resolved action output
|
|
236
|
+
*/
|
|
237
|
+
async function call(name, input = {}) {
|
|
238
|
+
const url = `${_base}${prefix}/${encodeURIComponent(name)}`;
|
|
239
|
+
const res = await globalThis.fetch(url, {
|
|
240
|
+
method: 'POST',
|
|
241
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
242
|
+
body: JSON.stringify({ input }),
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const json = await res.json();
|
|
246
|
+
|
|
247
|
+
if (!res.ok || json.error) {
|
|
248
|
+
const err = new Error(json.error ?? `Server action "${name}" failed (${res.status})`);
|
|
249
|
+
err.status = res.status;
|
|
250
|
+
if (typeof onError === 'function') onError(err);
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return json.data;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { call, baseUrl: _base, prefix };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── Client-side: useServerAction hook ───────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Reactive hook for calling a server action.
|
|
264
|
+
*
|
|
265
|
+
* Returns reactive signals for loading, error, and data states.
|
|
266
|
+
* Supports optimistic updates via the `optimistic` option.
|
|
267
|
+
*
|
|
268
|
+
* @template I, O
|
|
269
|
+
* @param {ServerClient} client — from createServerClient()
|
|
270
|
+
* @param {string} name — server action name
|
|
271
|
+
* @param {object} [opts]
|
|
272
|
+
* @param {O} [opts.initialData] — initial value for data signal
|
|
273
|
+
* @param {Function} [opts.optimistic] — (input, currentData) => O — optimistic updater
|
|
274
|
+
* @param {Function} [opts.onSuccess] — (data: O) => void
|
|
275
|
+
* @param {Function} [opts.onError] — (err: Error) => void
|
|
276
|
+
*
|
|
277
|
+
* @returns {{
|
|
278
|
+
* execute: (input?: I) => Promise<O>,
|
|
279
|
+
* loading: Signal<boolean>,
|
|
280
|
+
* error: Signal<Error|null>,
|
|
281
|
+
* data: Signal<O|null>,
|
|
282
|
+
* reset: () => void,
|
|
283
|
+
* }}
|
|
284
|
+
*/
|
|
285
|
+
export function useServerAction(client, name, {
|
|
286
|
+
initialData = null,
|
|
287
|
+
optimistic = null,
|
|
288
|
+
onSuccess = null,
|
|
289
|
+
onError = null,
|
|
290
|
+
} = {}) {
|
|
291
|
+
const loading = signal(false);
|
|
292
|
+
const error = signal(null);
|
|
293
|
+
const data = signal(initialData);
|
|
294
|
+
|
|
295
|
+
// Snapshot of data before optimistic update (for rollback)
|
|
296
|
+
let _prevData = initialData;
|
|
297
|
+
|
|
298
|
+
async function execute(input = {}) {
|
|
299
|
+
loading.set(true);
|
|
300
|
+
error.set(null);
|
|
301
|
+
|
|
302
|
+
// Optimistic update
|
|
303
|
+
if (typeof optimistic === 'function') {
|
|
304
|
+
_prevData = data.get();
|
|
305
|
+
data.set(optimistic(input, _prevData));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
const result = await client.call(name, input);
|
|
310
|
+
data.set(result);
|
|
311
|
+
if (typeof onSuccess === 'function') onSuccess(result);
|
|
312
|
+
return result;
|
|
313
|
+
} catch (err) {
|
|
314
|
+
// Rollback optimistic update
|
|
315
|
+
if (typeof optimistic === 'function') {
|
|
316
|
+
data.set(_prevData);
|
|
317
|
+
}
|
|
318
|
+
error.set(err);
|
|
319
|
+
if (typeof onError === 'function') onError(err);
|
|
320
|
+
throw err;
|
|
321
|
+
} finally {
|
|
322
|
+
loading.set(false);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function reset() {
|
|
327
|
+
loading.set(false);
|
|
328
|
+
error.set(null);
|
|
329
|
+
data.set(initialData);
|
|
330
|
+
_prevData = initialData;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return { execute, loading, error, data, reset };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ─── "use server" directive support ──────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Auto-register all exported server actions from a module that uses
|
|
340
|
+
* the "use server" convention.
|
|
341
|
+
*
|
|
342
|
+
* In a Clarity + Vite pipeline the compiler can insert this call automatically
|
|
343
|
+
* for any module whose first line is `'use server'`.
|
|
344
|
+
*
|
|
345
|
+
* @param {object} mod — ES module namespace object
|
|
346
|
+
*/
|
|
347
|
+
export function registerServerModule(mod) {
|
|
348
|
+
for (const [, fn] of Object.entries(mod ?? {})) {
|
|
349
|
+
if (typeof fn === 'function' && fn.__serverAction__) {
|
|
350
|
+
_actionRegistry.set(fn.__serverAction__, fn.__handler__);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ─── Middleware helpers ───────────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Compose multiple server action middleware functions.
|
|
359
|
+
* Each middleware receives (name, input, next) and must call next().
|
|
360
|
+
*
|
|
361
|
+
* @param {...Function} middlewares
|
|
362
|
+
* @returns {Function} composed handler: (name, input) => Promise<*>
|
|
363
|
+
*
|
|
364
|
+
* @example
|
|
365
|
+
* const handler = composeActionMiddleware(
|
|
366
|
+
* authMiddleware,
|
|
367
|
+
* logMiddleware,
|
|
368
|
+
* );
|
|
369
|
+
* app.post('/_clarity/actions/:name', async (req, res) => {
|
|
370
|
+
* const data = await handler(req.params.name, req.body.input);
|
|
371
|
+
* res.json({ data });
|
|
372
|
+
* });
|
|
373
|
+
*/
|
|
374
|
+
export function composeActionMiddleware(...middlewares) {
|
|
375
|
+
return async function dispatch(name, input) {
|
|
376
|
+
let idx = -1;
|
|
377
|
+
|
|
378
|
+
async function next(i) {
|
|
379
|
+
if (i <= idx) throw new Error('[Server Actions] next() called multiple times');
|
|
380
|
+
idx = i;
|
|
381
|
+
|
|
382
|
+
if (i < middlewares.length) {
|
|
383
|
+
return middlewares[i](name, input, () => next(i + 1));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Core dispatch
|
|
387
|
+
const handler = _actionRegistry.get(name);
|
|
388
|
+
if (!handler) {
|
|
389
|
+
const err = new Error(`Unknown server action: "${name}"`);
|
|
390
|
+
err.status = 404;
|
|
391
|
+
throw err;
|
|
392
|
+
}
|
|
393
|
+
return handler(input);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return next(0);
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Built-in logging middleware — logs action name, input size, duration, and result.
|
|
402
|
+
*
|
|
403
|
+
* @param {string} name
|
|
404
|
+
* @param {*} input
|
|
405
|
+
* @param {Function} next
|
|
406
|
+
*/
|
|
407
|
+
export async function logActionMiddleware(name, input, next) {
|
|
408
|
+
const t0 = Date.now();
|
|
409
|
+
try {
|
|
410
|
+
const result = await next();
|
|
411
|
+
const ms = Date.now() - t0;
|
|
412
|
+
console.log(`[Server Action] ${name} — ${ms}ms ✓`);
|
|
413
|
+
return result;
|
|
414
|
+
} catch (err) {
|
|
415
|
+
const ms = Date.now() - t0;
|
|
416
|
+
console.error(`[Server Action] ${name} — ${ms}ms ✗ ${err.message}`);
|
|
417
|
+
throw err;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Built-in auth middleware — validates a Bearer token in headers.
|
|
423
|
+
* Inject into composeActionMiddleware() before your action logic.
|
|
424
|
+
*
|
|
425
|
+
* @param {Function} getToken — (name, input) => string | null — extract token from context
|
|
426
|
+
* @param {Function} verify — (token) => boolean | Promise<boolean> — validate token
|
|
427
|
+
*/
|
|
428
|
+
export function createAuthMiddleware(getToken, verify) {
|
|
429
|
+
return async function authMiddleware(name, input, next) {
|
|
430
|
+
const token = typeof getToken === 'function' ? getToken(name, input) : null;
|
|
431
|
+
const valid = token ? await verify(token) : false;
|
|
432
|
+
if (!valid) {
|
|
433
|
+
const err = new Error('Unauthorized');
|
|
434
|
+
err.status = 401;
|
|
435
|
+
throw err;
|
|
436
|
+
}
|
|
437
|
+
return next();
|
|
438
|
+
};
|
|
439
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clarity.js — Component-level SSR data fetching (`useServerData`)
|
|
3
|
+
*
|
|
4
|
+
* The companion to the file router's `loader()` export: a hook any component
|
|
5
|
+
* can call to fetch data on the server, have it serialized into the HTML, and
|
|
6
|
+
* hydrate on the client WITHOUT re-fetching. On client-side navigation (no
|
|
7
|
+
* server pass) it falls back to fetching in the browser, like SWR/createQuery.
|
|
8
|
+
*
|
|
9
|
+
* import { useServerData } from '@ozsarman/clarityjs/server-data';
|
|
10
|
+
*
|
|
11
|
+
* component Posts() {
|
|
12
|
+
* const posts = useServerData('posts', () => fetch('/api/posts').then(r => r.json()));
|
|
13
|
+
* // posts.data() · posts.loading() · posts.error() · posts.refetch()
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* Render side (Express / edge):
|
|
17
|
+
*
|
|
18
|
+
* import { renderWithServerData } from '@ozsarman/clarityjs/server-data';
|
|
19
|
+
* const { html, state } = await renderWithServerData(Posts, props, ctx);
|
|
20
|
+
* // embed state into window.__CLARITY_DATA__ (renderToDocumentWithData does this)
|
|
21
|
+
*
|
|
22
|
+
* Author: Claude (Anthropic) + Özdemir Sarman
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { signal } from './runtime.js';
|
|
26
|
+
import { renderToString } from './ssr.js';
|
|
27
|
+
|
|
28
|
+
// ─── SSR collection context (null in the browser) ─────────────────────────────
|
|
29
|
+
// During a server render, `_ctx` holds:
|
|
30
|
+
// data — resolved key → value map (populated on the 2nd pass)
|
|
31
|
+
// pending — [{ key, fetcher }] collected on the 1st pass
|
|
32
|
+
// keys — every key requested (for diagnostics)
|
|
33
|
+
let _ctx = null;
|
|
34
|
+
|
|
35
|
+
/** @internal — begin an SSR data collection scope. */
|
|
36
|
+
export function _beginServerData(data = {}) {
|
|
37
|
+
_ctx = { data, pending: [], keys: new Set() };
|
|
38
|
+
return _ctx;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @internal — end the current scope and return it. */
|
|
42
|
+
export function _endServerData() {
|
|
43
|
+
const ctx = _ctx;
|
|
44
|
+
_ctx = null;
|
|
45
|
+
return ctx;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @internal — are we currently collecting on the server? */
|
|
49
|
+
export function _isCollectingServerData() {
|
|
50
|
+
return _ctx !== null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── Hydration read (client) ──────────────────────────────────────────────────
|
|
54
|
+
const SERVER_DATA_KEY = '__serverData';
|
|
55
|
+
|
|
56
|
+
function _readHydrated(key) {
|
|
57
|
+
if (typeof window === 'undefined') return undefined;
|
|
58
|
+
const all = window.__CLARITY_DATA__;
|
|
59
|
+
const bag = all && all[SERVER_DATA_KEY];
|
|
60
|
+
return bag && Object.prototype.hasOwnProperty.call(bag, key) ? bag[key] : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── Result shape ─────────────────────────────────────────────────────────────
|
|
64
|
+
function _result(dataSig, loadingSig, errorSig, fetcher) {
|
|
65
|
+
const refetch = () => {
|
|
66
|
+
if (typeof fetcher !== 'function') return Promise.resolve(dataSig.get());
|
|
67
|
+
loadingSig.set(true);
|
|
68
|
+
errorSig.set(null);
|
|
69
|
+
return Promise.resolve()
|
|
70
|
+
.then(() => fetcher())
|
|
71
|
+
.then(d => { dataSig.set(d); loadingSig.set(false); return d; })
|
|
72
|
+
.catch(e => { errorSig.set(e); loadingSig.set(false); throw e; });
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
// Signal-style accessors (call to read, reactive inside effects/render)
|
|
77
|
+
data: Object.assign(() => dataSig.get(), dataSig),
|
|
78
|
+
loading: Object.assign(() => loadingSig.get(), loadingSig),
|
|
79
|
+
error: Object.assign(() => errorSig.get(), errorSig),
|
|
80
|
+
refetch,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Fetch data on the server, serialize it, and hydrate it on the client.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} key Stable key used to serialize + look up the data
|
|
88
|
+
* @param {Function} fetcher () => value | Promise<value> (receives the server ctx on the server)
|
|
89
|
+
* @param {object} [options]
|
|
90
|
+
* @param {*} [options.initialData] Seed value before the first fetch resolves
|
|
91
|
+
* @returns {{ data, loading, error, refetch }}
|
|
92
|
+
*/
|
|
93
|
+
export function useServerData(key, fetcher, options = {}) {
|
|
94
|
+
const initial = options.initialData;
|
|
95
|
+
|
|
96
|
+
// ── Server path — we are inside renderWithServerData ──────────────────────
|
|
97
|
+
if (_ctx) {
|
|
98
|
+
_ctx.keys.add(key);
|
|
99
|
+
if (Object.prototype.hasOwnProperty.call(_ctx.data, key)) {
|
|
100
|
+
// 2nd pass: data resolved → seed synchronously.
|
|
101
|
+
return _result(signal(_ctx.data[key]), signal(false), signal(null), fetcher);
|
|
102
|
+
}
|
|
103
|
+
// 1st pass: register the fetcher to be awaited, render with the seed value.
|
|
104
|
+
_ctx.pending.push({ key, fetcher });
|
|
105
|
+
return _result(signal(initial), signal(true), signal(null), fetcher);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Client path ───────────────────────────────────────────────────────────
|
|
109
|
+
const hydrated = _readHydrated(key);
|
|
110
|
+
if (hydrated !== undefined) {
|
|
111
|
+
// Server already fetched this — adopt it, no client fetch.
|
|
112
|
+
return _result(signal(hydrated), signal(false), signal(null), fetcher);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// No server data (client navigation / pure SPA) → fetch in the browser.
|
|
116
|
+
const dataSig = signal(initial);
|
|
117
|
+
const loadingSig = signal(true);
|
|
118
|
+
const errorSig = signal(null);
|
|
119
|
+
Promise.resolve()
|
|
120
|
+
.then(() => (typeof fetcher === 'function' ? fetcher() : initial))
|
|
121
|
+
.then(d => { dataSig.set(d); loadingSig.set(false); })
|
|
122
|
+
.catch(e => { errorSig.set(e); loadingSig.set(false); });
|
|
123
|
+
|
|
124
|
+
return _result(dataSig, loadingSig, errorSig, fetcher);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Server render orchestrator ───────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Render a component to HTML, resolving every `useServerData()` call first.
|
|
131
|
+
*
|
|
132
|
+
* Two-pass strategy: render once to collect the requested keys + fetchers,
|
|
133
|
+
* await them, then render again with the resolved data. If the component uses
|
|
134
|
+
* no server data, the second pass is skipped. The resolved data is merged into
|
|
135
|
+
* `state.__serverData` so it can be embedded for client hydration.
|
|
136
|
+
*
|
|
137
|
+
* @param {Function} componentFn
|
|
138
|
+
* @param {object} [props={}]
|
|
139
|
+
* @param {object} [ctx={}] Server context forwarded to each fetcher
|
|
140
|
+
* @returns {Promise<{ html, state, aiContracts, serverData }>}
|
|
141
|
+
*/
|
|
142
|
+
export async function renderWithServerData(componentFn, props = {}, ctx = {}) {
|
|
143
|
+
// Pass 1 — collect.
|
|
144
|
+
_beginServerData({});
|
|
145
|
+
let result, collected;
|
|
146
|
+
try {
|
|
147
|
+
result = renderToString(componentFn, props);
|
|
148
|
+
} finally {
|
|
149
|
+
collected = _endServerData();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!collected || collected.pending.length === 0) {
|
|
153
|
+
return { ...result, serverData: {} };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Resolve all pending fetchers (deduped by key — last writer wins).
|
|
157
|
+
const data = {};
|
|
158
|
+
await Promise.all(
|
|
159
|
+
collected.pending.map(async ({ key, fetcher }) => {
|
|
160
|
+
data[key] = typeof fetcher === 'function' ? await fetcher(ctx) : undefined;
|
|
161
|
+
})
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Pass 2 — render with resolved data.
|
|
165
|
+
_beginServerData(data);
|
|
166
|
+
try {
|
|
167
|
+
result = renderToString(componentFn, props);
|
|
168
|
+
} finally {
|
|
169
|
+
_endServerData();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Merge server data into the serialized state for hydration.
|
|
173
|
+
const state = { ...result.state, [SERVER_DATA_KEY]: data };
|
|
174
|
+
return { ...result, state, serverData: data };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Convenience: render a full HTML document with server data embedded into
|
|
179
|
+
* window.__CLARITY_DATA__ so client `useServerData()` calls hydrate without
|
|
180
|
+
* re-fetching.
|
|
181
|
+
*
|
|
182
|
+
* @param {Function} componentFn
|
|
183
|
+
* @param {object} [options]
|
|
184
|
+
* @param {object} [options.props]
|
|
185
|
+
* @param {object} [options.ctx]
|
|
186
|
+
* @param {string} [options.title]
|
|
187
|
+
* @param {string} [options.lang]
|
|
188
|
+
* @param {string} [options.clientScript]
|
|
189
|
+
* @param {string} [options.styles]
|
|
190
|
+
* @returns {Promise<string>}
|
|
191
|
+
*/
|
|
192
|
+
export async function renderToDocumentWithData(componentFn, options = {}) {
|
|
193
|
+
const {
|
|
194
|
+
props = {}, ctx = {}, title = '', lang = 'en',
|
|
195
|
+
clientScript = null, styles = null,
|
|
196
|
+
} = options;
|
|
197
|
+
|
|
198
|
+
const { html, state } = await renderWithServerData(componentFn, props, ctx);
|
|
199
|
+
|
|
200
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => (
|
|
201
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
|
202
|
+
));
|
|
203
|
+
const stateScript = Object.keys(state).length > 0
|
|
204
|
+
? `<script>window.__CLARITY_DATA__=${JSON.stringify(state)};</script>`
|
|
205
|
+
: '';
|
|
206
|
+
const clientTag = clientScript ? `<script type="module" src="${esc(clientScript)}"></script>` : '';
|
|
207
|
+
const styleTag = styles ? `<style>${styles}</style>` : '';
|
|
208
|
+
|
|
209
|
+
return [
|
|
210
|
+
`<!DOCTYPE html>`,
|
|
211
|
+
`<html lang="${esc(lang)}">`,
|
|
212
|
+
`<head>`,
|
|
213
|
+
`<meta charset="utf-8">`,
|
|
214
|
+
`<meta name="viewport" content="width=device-width,initial-scale=1">`,
|
|
215
|
+
title ? `<title>${esc(title)}</title>` : '',
|
|
216
|
+
styleTag,
|
|
217
|
+
`</head>`,
|
|
218
|
+
`<body>`,
|
|
219
|
+
`<div id="app">${html}</div>`,
|
|
220
|
+
stateScript,
|
|
221
|
+
clientTag,
|
|
222
|
+
`</body>`,
|
|
223
|
+
`</html>`,
|
|
224
|
+
].filter(Boolean).join('\n');
|
|
225
|
+
}
|