@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/src/edge.js ADDED
@@ -0,0 +1,606 @@
1
+ /**
2
+ * Clarity.js — Edge Runtime
3
+ *
4
+ * Cloudflare Workers, Deno Deploy, Vercel Edge, Bun ve her türlü
5
+ * Web-standard Fetch API ortamında Clarity SSR + Server Actions çalıştırır.
6
+ *
7
+ * Node.js API'lerine (fs, path, stream, process) bağımlılık yoktur.
8
+ * Yalnızca Web standardları kullanılır:
9
+ * Request, Response, URL, Headers, ReadableStream, TextEncoder, crypto
10
+ *
11
+ * ─── Kullanım (Cloudflare Workers) ──────────────────────────────────────────
12
+ *
13
+ * import { createEdgeHandler } from '@ozsarman/clarityjs/edge';
14
+ * import { renderToString } from '@ozsarman/clarityjs/ssr';
15
+ * import App from './App.js';
16
+ *
17
+ * export default createEdgeHandler({
18
+ * render: (req) => renderToString(App, { url: req.url }),
19
+ * actions: () => import('./actions/index.js'),
20
+ * });
21
+ *
22
+ * ─── Kullanım (Vercel Edge Function) ────────────────────────────────────────
23
+ *
24
+ * import { adaptFetchHandler } from '@ozsarman/clarityjs/edge';
25
+ * export const config = { runtime: 'edge' };
26
+ * export default adaptFetchHandler(myHandler);
27
+ *
28
+ * ─── Kullanım (Deno Deploy) ─────────────────────────────────────────────────
29
+ *
30
+ * import { createEdgeHandler } from '@ozsarman/clarityjs/edge';
31
+ * Deno.serve(createEdgeHandler({ render, actions }).fetch);
32
+ *
33
+ * Author: Claude (Anthropic) + Özdemir Sarman
34
+ */
35
+
36
+ // ─── Edge environment detection ───────────────────────────────────────────────
37
+
38
+ /**
39
+ * Detects the current edge runtime environment.
40
+ * @returns {'cloudflare' | 'deno' | 'bun' | 'vercel-edge' | 'workerd' | 'node' | 'unknown'}
41
+ */
42
+ export function detectEdgeRuntime() {
43
+ if (typeof globalThis.EdgeRuntime !== 'undefined') return 'vercel-edge';
44
+ if (typeof globalThis.Deno !== 'undefined') return 'deno';
45
+ if (typeof globalThis.Bun !== 'undefined') return 'bun';
46
+ if (typeof globalThis.caches !== 'undefined' &&
47
+ typeof globalThis.self !== 'undefined' &&
48
+ typeof globalThis.WorkerGlobalScope !== 'undefined') return 'cloudflare';
49
+ if (typeof process !== 'undefined' && process.versions?.node) return 'node';
50
+ return 'unknown';
51
+ }
52
+
53
+ /** True when running in any Web-standard (non-Node) edge environment. */
54
+ export const isEdgeRuntime =
55
+ typeof globalThis.EdgeRuntime !== 'undefined' ||
56
+ typeof globalThis.Deno !== 'undefined' ||
57
+ typeof globalThis.Bun !== 'undefined' ||
58
+ (typeof globalThis.caches !== 'undefined' &&
59
+ typeof globalThis.WorkerGlobalScope !== 'undefined');
60
+
61
+ // ─── Edge-compatible HTML escaping ────────────────────────────────────────────
62
+
63
+ const _ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
64
+ export function escapeHTML(s) { return String(s ?? '').replace(/[&<>"']/g, c => _ESC[c]); }
65
+
66
+ // ─── Edge Request wrapper ─────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Normalize any edge/fetch Request into a plain object Clarity SSR can consume.
70
+ *
71
+ * @param {Request} req
72
+ * @returns {EdgeRequest}
73
+ */
74
+ export function normalizeRequest(req) {
75
+ const url = new URL(req.url);
76
+ const method = req.method.toUpperCase();
77
+
78
+ return {
79
+ url: req.url,
80
+ method,
81
+ pathname: url.pathname,
82
+ search: url.search,
83
+ query: Object.fromEntries(url.searchParams),
84
+ headers: Object.fromEntries(req.headers),
85
+ body: req.body,
86
+
87
+ /** Parse JSON body (async). */
88
+ async json() { return req.json(); },
89
+ /** Parse text body (async). */
90
+ async text() { return req.text(); },
91
+ /** Parse FormData body (async). */
92
+ async form() { return req.formData(); },
93
+
94
+ /** Get a single header (case-insensitive). */
95
+ header(name) { return req.headers.get(name); },
96
+
97
+ /** True when the request expects HTML (Accept: text/html). */
98
+ get acceptsHTML() {
99
+ return (req.headers.get('accept') ?? '').includes('text/html');
100
+ },
101
+
102
+ /** True when this looks like a Clarity Server Action call. */
103
+ get isAction() {
104
+ return method === 'POST' &&
105
+ url.pathname.startsWith('/_clarity/actions/');
106
+ },
107
+
108
+ /** Extract the action name from the URL path. */
109
+ get actionName() {
110
+ return url.pathname.replace('/_clarity/actions/', '');
111
+ },
112
+ };
113
+ }
114
+
115
+ // ─── Edge Response helpers ────────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Send an HTML response.
119
+ *
120
+ * @param {string} html
121
+ * @param {object} [opts]
122
+ * @param {number} [opts.status=200]
123
+ * @param {object} [opts.headers]
124
+ * @returns {Response}
125
+ */
126
+ export function htmlResponse(html, { status = 200, headers = {} } = {}) {
127
+ return new Response(html, {
128
+ status,
129
+ headers: {
130
+ 'Content-Type': 'text/html; charset=utf-8',
131
+ ...headers,
132
+ },
133
+ });
134
+ }
135
+
136
+ /**
137
+ * Send a JSON response.
138
+ *
139
+ * @param {*} data
140
+ * @param {object} [opts]
141
+ * @param {number} [opts.status=200]
142
+ * @param {object} [opts.headers]
143
+ * @returns {Response}
144
+ */
145
+ export function jsonResponse(data, { status = 200, headers = {} } = {}) {
146
+ return new Response(JSON.stringify(data), {
147
+ status,
148
+ headers: {
149
+ 'Content-Type': 'application/json',
150
+ ...headers,
151
+ },
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Redirect response (301 / 302 / 307 / 308).
157
+ *
158
+ * @param {string} location
159
+ * @param {number} [status=302]
160
+ * @returns {Response}
161
+ */
162
+ export function redirectResponse(location, status = 302) {
163
+ return new Response(null, { status, headers: { Location: location } });
164
+ }
165
+
166
+ /**
167
+ * Streaming HTML response using ReadableStream.
168
+ * Useful for progressive SSR on edge runtimes that support streaming.
169
+ *
170
+ * @param {AsyncIterable<string> | ReadableStream} stream
171
+ * @param {object} [opts]
172
+ * @returns {Response}
173
+ */
174
+ export function streamResponse(stream, { status = 200, headers = {} } = {}) {
175
+ const encoder = new TextEncoder();
176
+
177
+ const readable = stream[Symbol.asyncIterator]
178
+ ? new ReadableStream({
179
+ async start(controller) {
180
+ try {
181
+ for await (const chunk of stream) {
182
+ controller.enqueue(
183
+ typeof chunk === 'string' ? encoder.encode(chunk) : chunk
184
+ );
185
+ }
186
+ controller.close();
187
+ } catch (err) {
188
+ controller.error(err);
189
+ }
190
+ },
191
+ })
192
+ : stream;
193
+
194
+ return new Response(readable, {
195
+ status,
196
+ headers: {
197
+ 'Content-Type': 'text/html; charset=utf-8',
198
+ 'Transfer-Encoding': 'chunked',
199
+ 'X-Content-Type-Options': 'nosniff',
200
+ ...headers,
201
+ },
202
+ });
203
+ }
204
+
205
+ // ─── Edge-compatible SSR ──────────────────────────────────────────────────────
206
+
207
+ /**
208
+ * Render a Clarity component to an HTML string without any Node.js APIs.
209
+ *
210
+ * Delegates to `renderToString` from ssr.js but wraps it in a try/catch
211
+ * with edge-compatible error formatting.
212
+ *
213
+ * @param {Function} ComponentFn — compiled Clarity component
214
+ * @param {object} [props]
215
+ * @param {object} [opts]
216
+ * @param {string} [opts.title]
217
+ * @param {string} [opts.lang='en']
218
+ * @param {string} [opts.charset='UTF-8']
219
+ * @param {string[]} [opts.scripts] — script URLs to inject
220
+ * @param {string[]} [opts.styles] — stylesheet URLs to inject
221
+ * @param {string} [opts.headExtra] — raw HTML injected in <head>
222
+ * @param {string} [opts.bodyAttr] — extra attributes on <body>
223
+ * @returns {{ html: string, state: object }}
224
+ */
225
+ export async function renderEdge(ComponentFn, props = {}, opts = {}) {
226
+ const {
227
+ title = 'Clarity App',
228
+ lang = 'en',
229
+ charset = 'UTF-8',
230
+ scripts = [],
231
+ styles = [],
232
+ headExtra = '',
233
+ bodyAttr = '',
234
+ } = opts;
235
+
236
+ // Minimal synchronous SSR — builds HTML without Node stream APIs
237
+ let bodyHTML = '';
238
+ let state = {};
239
+
240
+ try {
241
+ // Dynamic import keeps ssr.js Node-independent in edge bundling
242
+ const { renderToString } = await import('./ssr.js');
243
+ const result = renderToString(ComponentFn, props);
244
+ bodyHTML = result?.html ?? String(result ?? '');
245
+ state = result?.state ?? {};
246
+ } catch (err) {
247
+ // On edge we can't use process.exit(); just embed error in HTML
248
+ bodyHTML = `<clarity-error>${escapeHTML(err.message)}</clarity-error>`;
249
+ }
250
+
251
+ const styleLinks = styles.map(href => `<link rel="stylesheet" href="${escapeHTML(href)}">`).join('\n ');
252
+ const scriptTags = scripts.map(src => `<script type="module" src="${escapeHTML(src)}"></script>`).join('\n ');
253
+ const stateScript = Object.keys(state).length > 0
254
+ ? `<script>window.__CLARITY_STATE__=${JSON.stringify(state)}</script>`
255
+ : '';
256
+
257
+ const html = `<!DOCTYPE html>
258
+ <html lang="${escapeHTML(lang)}">
259
+ <head>
260
+ <meta charset="${escapeHTML(charset)}">
261
+ <meta name="viewport" content="width=device-width, initial-scale=1">
262
+ <title>${escapeHTML(title)}</title>
263
+ ${styleLinks}
264
+ ${headExtra}
265
+ </head>
266
+ <body${bodyAttr ? ' ' + bodyAttr : ''}>
267
+ <div id="app">${bodyHTML}</div>
268
+ ${stateScript}
269
+ ${scriptTags}
270
+ </body>
271
+ </html>`;
272
+
273
+ return { html, state };
274
+ }
275
+
276
+ // ─── Edge handler factory ─────────────────────────────────────────────────────
277
+
278
+ /**
279
+ * Create a fully self-contained edge fetch handler.
280
+ *
281
+ * Handles:
282
+ * - Server Actions → POST /_clarity/actions/:name
283
+ * - SSR pages → GET any route
284
+ * - Static assets → GET /assets/*, /public/* (pass-through to next handler)
285
+ *
286
+ * @param {object} opts
287
+ * @param {Function} opts.render — async (edgeReq) => { html, state } | Response
288
+ * @param {Function} [opts.actions] — async () => actionModule (lazy import)
289
+ * @param {Function} [opts.middleware] — async (edgeReq, next) => Response
290
+ * @param {Function} [opts.onError] — (err, edgeReq) => Response
291
+ * @param {string[]} [opts.staticPaths] — prefixes to skip (e.g. ['/assets'])
292
+ * @param {object} [opts.cors] — CORS options
293
+ * @param {boolean} [opts.cors.enabled=false]
294
+ * @param {string} [opts.cors.origin='*']
295
+ * @param {string[]} [opts.cors.methods]
296
+ * @returns {{ fetch: (req: Request, env?: any, ctx?: any) => Promise<Response> }}
297
+ */
298
+ export function createEdgeHandler({
299
+ render,
300
+ actions,
301
+ middleware,
302
+ onError,
303
+ staticPaths = ['/assets/', '/public/', '/favicon', '/_next/'],
304
+ cors = { enabled: false, origin: '*', methods: ['GET', 'POST', 'OPTIONS'] },
305
+ } = {}) {
306
+ // Lazy-loaded action registry
307
+ let _actionModule = null;
308
+
309
+ async function _getActions() {
310
+ if (!_actionModule && typeof actions === 'function') {
311
+ _actionModule = await actions();
312
+ }
313
+ return _actionModule ?? {};
314
+ }
315
+
316
+ async function _dispatchAction(name, input) {
317
+ const mod = await _getActions();
318
+
319
+ // Find matching action by __serverAction__ metadata or key name
320
+ for (const [, fn] of Object.entries(mod)) {
321
+ if (fn?.__serverAction__ === name || fn?.name === name) {
322
+ const handler = fn.__handler__ ?? fn;
323
+ return handler(input);
324
+ }
325
+ }
326
+
327
+ // Also check clarity-js/server-actions registry (if loaded)
328
+ if (typeof globalThis.__clarityActionRegistry__ !== 'undefined') {
329
+ const handler = globalThis.__clarityActionRegistry__.get(name);
330
+ if (handler) return handler(input);
331
+ }
332
+
333
+ const err = new Error(`Unknown server action: "${name}"`);
334
+ err.status = 404;
335
+ throw err;
336
+ }
337
+
338
+ async function handle(request, env, ctx) {
339
+ const edgeReq = normalizeRequest(request);
340
+
341
+ try {
342
+ // ── CORS preflight ────────────────────────────────────────────────────
343
+ if (cors.enabled && request.method === 'OPTIONS') {
344
+ return new Response(null, {
345
+ status: 204,
346
+ headers: {
347
+ 'Access-Control-Allow-Origin': cors.origin ?? '*',
348
+ 'Access-Control-Allow-Methods': (cors.methods ?? ['GET','POST','OPTIONS']).join(', '),
349
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
350
+ },
351
+ });
352
+ }
353
+
354
+ // ── Static asset pass-through ─────────────────────────────────────────
355
+ for (const prefix of staticPaths) {
356
+ if (edgeReq.pathname.startsWith(prefix)) {
357
+ // Return 404 — real static serving is handled by the edge platform
358
+ return new Response('Not Found', { status: 404 });
359
+ }
360
+ }
361
+
362
+ // ── Optional middleware ───────────────────────────────────────────────
363
+ if (typeof middleware === 'function') {
364
+ const result = await middleware(edgeReq, async () => null);
365
+ if (result instanceof Response) return result;
366
+ }
367
+
368
+ // ── Server Actions ────────────────────────────────────────────────────
369
+ if (edgeReq.isAction && typeof actions !== 'undefined') {
370
+ const name = edgeReq.actionName;
371
+ let input = {};
372
+
373
+ try {
374
+ const body = await request.json();
375
+ input = body?.input ?? body ?? {};
376
+ } catch { /* empty body */ }
377
+
378
+ try {
379
+ const data = await _dispatchAction(name, input);
380
+ const res = jsonResponse({ data });
381
+ if (cors.enabled) res.headers.set('Access-Control-Allow-Origin', cors.origin ?? '*');
382
+ return res;
383
+ } catch (err) {
384
+ const status = err.status ?? 500;
385
+ return jsonResponse({ error: err.message ?? 'Internal server error' }, { status });
386
+ }
387
+ }
388
+
389
+ // ── SSR render ───────────────────────────────────────────────────────
390
+ if (typeof render === 'function') {
391
+ const result = await render(edgeReq, env, ctx);
392
+ if (result instanceof Response) return result;
393
+
394
+ const html = result?.html ?? String(result ?? '');
395
+ const status = result?.status ?? 200;
396
+ const res = htmlResponse(html, { status, headers: result?.headers ?? {} });
397
+ if (cors.enabled) res.headers.set('Access-Control-Allow-Origin', cors.origin ?? '*');
398
+ return res;
399
+ }
400
+
401
+ return new Response('Clarity Edge Handler: no render function configured', { status: 500 });
402
+
403
+ } catch (err) {
404
+ if (typeof onError === 'function') {
405
+ const errRes = await onError(err, edgeReq);
406
+ if (errRes instanceof Response) return errRes;
407
+ }
408
+ return new Response(
409
+ `Internal Server Error: ${err.message ?? 'Unknown error'}`,
410
+ { status: 500, headers: { 'Content-Type': 'text/plain' } }
411
+ );
412
+ }
413
+ }
414
+
415
+ return {
416
+ /** Cloudflare Workers / Bun / Deno standard fetch handler. */
417
+ fetch: handle,
418
+ /** Vercel Edge Function default export (same signature). */
419
+ default: handle,
420
+ };
421
+ }
422
+
423
+ // ─── Vercel Edge Function adapter ────────────────────────────────────────────
424
+
425
+ /**
426
+ * Wrap any `(req: Request) => Response | Promise<Response>` function
427
+ * for Vercel Edge Function deployment.
428
+ *
429
+ * @param {Function} handler
430
+ * @param {object} [config] — Vercel edge config (runtime, regions, etc.)
431
+ * @returns {{ default: Function, config: object }}
432
+ */
433
+ export function adaptFetchHandler(handler, config = {}) {
434
+ return {
435
+ default: (req) => handler(req),
436
+ config: { runtime: 'edge', ...config },
437
+ };
438
+ }
439
+
440
+ // ─── Edge KV / Cache helpers ──────────────────────────────────────────────────
441
+
442
+ /**
443
+ * Edge-compatible in-memory cache (falls back to platform KV when available).
444
+ * Works across Cloudflare Workers (caches API), Deno KV, Vercel Edge cache.
445
+ *
446
+ * @param {string} [namespace='clarity']
447
+ * @returns {EdgeCache}
448
+ */
449
+ export function createEdgeCache(namespace = 'clarity') {
450
+ // In-memory fallback (per-request in stateless workers, longer in Deno/Bun)
451
+ const _mem = new Map();
452
+
453
+ return {
454
+ /**
455
+ * Get a cached value.
456
+ * @param {string} key
457
+ * @returns {Promise<string | null>}
458
+ */
459
+ async get(key) {
460
+ const fullKey = `${namespace}:${key}`;
461
+
462
+ // Cloudflare Workers cache API
463
+ if (typeof caches !== 'undefined') {
464
+ try {
465
+ const cache = await caches.open(namespace);
466
+ const res = await cache.match(new Request(`https://clarity-cache/${fullKey}`));
467
+ if (res) return res.text();
468
+ } catch { /* fall through to memory */ }
469
+ }
470
+
471
+ // Deno KV
472
+ if (typeof globalThis.Deno?.openKv === 'function') {
473
+ try {
474
+ const kv = await globalThis.Deno.openKv();
475
+ const result = await kv.get([namespace, key]);
476
+ return result.value ?? null;
477
+ } catch { /* fall through */ }
478
+ }
479
+
480
+ return _mem.get(fullKey) ?? null;
481
+ },
482
+
483
+ /**
484
+ * Set a cached value.
485
+ * @param {string} key
486
+ * @param {string} value
487
+ * @param {number} [ttl] — seconds
488
+ */
489
+ async set(key, value, ttl) {
490
+ const fullKey = `${namespace}:${key}`;
491
+
492
+ if (typeof caches !== 'undefined') {
493
+ try {
494
+ const cache = await caches.open(namespace);
495
+ const res = new Response(value, {
496
+ headers: ttl
497
+ ? { 'Cache-Control': `max-age=${ttl}` }
498
+ : {},
499
+ });
500
+ await cache.put(new Request(`https://clarity-cache/${fullKey}`), res);
501
+ return;
502
+ } catch { /* fall through */ }
503
+ }
504
+
505
+ if (typeof globalThis.Deno?.openKv === 'function') {
506
+ try {
507
+ const kv = await globalThis.Deno.openKv();
508
+ const opts = ttl ? { expireIn: ttl * 1000 } : undefined;
509
+ await kv.set([namespace, key], value, opts);
510
+ return;
511
+ } catch { /* fall through */ }
512
+ }
513
+
514
+ _mem.set(fullKey, value);
515
+ if (ttl) setTimeout(() => _mem.delete(fullKey), ttl * 1000);
516
+ },
517
+
518
+ /** Delete a cached key. */
519
+ async delete(key) {
520
+ const fullKey = `${namespace}:${key}`;
521
+
522
+ if (typeof caches !== 'undefined') {
523
+ try {
524
+ const cache = await caches.open(namespace);
525
+ await cache.delete(new Request(`https://clarity-cache/${fullKey}`));
526
+ } catch { /* fall through */ }
527
+ }
528
+
529
+ _mem.delete(fullKey);
530
+ },
531
+ };
532
+ }
533
+
534
+ // ─── Edge geolocation helper ──────────────────────────────────────────────────
535
+
536
+ /**
537
+ * Extract geolocation from edge request headers.
538
+ * Reads Cloudflare, Vercel, and Fastly geo headers.
539
+ *
540
+ * @param {Request} req
541
+ * @returns {{ country: string, city: string, region: string, timezone: string }}
542
+ */
543
+ export function getGeo(req) {
544
+ const h = (name) => req.headers.get(name) ?? '';
545
+
546
+ return {
547
+ country: h('cf-ipcountry') || h('x-vercel-ip-country') || h('x-country') || '',
548
+ city: h('cf-ipcity') || h('x-vercel-ip-city') || h('x-city') || '',
549
+ region: h('cf-region') || h('x-vercel-ip-country-region') || '',
550
+ timezone: h('cf-timezone') || h('x-vercel-ip-timezone') || '',
551
+ ip: h('cf-connecting-ip') || h('x-real-ip') || h('x-forwarded-for').split(',')[0] || '',
552
+ latitude: h('cf-iplongitude') || '',
553
+ longitude:h('cf-iplatitude') || '',
554
+ };
555
+ }
556
+
557
+ // ─── Edge environment variable access ────────────────────────────────────────
558
+
559
+ /**
560
+ * Get an environment variable in any edge runtime.
561
+ * Cloudflare: env object passed to fetch handler.
562
+ * Deno: Deno.env.get()
563
+ * Bun / Node: process.env
564
+ *
565
+ * @param {string} key
566
+ * @param {object} [env] — Cloudflare env object (from fetch handler 2nd arg)
567
+ * @param {string} [fallback]
568
+ * @returns {string | undefined}
569
+ */
570
+ export function getEnv(key, env, fallback) {
571
+ if (env && typeof env[key] !== 'undefined') return env[key];
572
+ if (typeof globalThis.Deno !== 'undefined') return globalThis.Deno.env.get(key) ?? fallback;
573
+ if (typeof process !== 'undefined') return process.env[key] ?? fallback;
574
+ return fallback;
575
+ }
576
+
577
+ // ─── Edge-compatible crypto ───────────────────────────────────────────────────
578
+
579
+ /**
580
+ * Generate a random ID using Web Crypto (available in all edge runtimes).
581
+ * @param {number} [bytes=16]
582
+ * @returns {string} hex string
583
+ */
584
+ export function randomId(bytes = 16) {
585
+ const buf = new Uint8Array(bytes);
586
+ (globalThis.crypto ?? crypto).getRandomValues(buf);
587
+ return Array.from(buf, b => b.toString(16).padStart(2, '0')).join('');
588
+ }
589
+
590
+ /**
591
+ * HMAC-SHA256 signature (Web Crypto — works on all edges).
592
+ *
593
+ * @param {string} secret
594
+ * @param {string} message
595
+ * @returns {Promise<string>} hex digest
596
+ */
597
+ export async function hmacSign(secret, message) {
598
+ const enc = new TextEncoder();
599
+ const key = await crypto.subtle.importKey(
600
+ 'raw', enc.encode(secret),
601
+ { name: 'HMAC', hash: 'SHA-256' },
602
+ false, ['sign']
603
+ );
604
+ const sig = await crypto.subtle.sign('HMAC', key, enc.encode(message));
605
+ return Array.from(new Uint8Array(sig), b => b.toString(16).padStart(2, '0')).join('');
606
+ }