@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
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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
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
|
+
}
|