@lengkapp/edge 0.0.39 → 0.0.41

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 CHANGED
@@ -1,438 +1,882 @@
1
- # Edge Libraries
1
+ # @lengkapp/edge
2
2
 
3
- Dependency-free edge runtime — HonoJS + htmx inspired. Used by **lengkapp**.
3
+ A minimal, high-performance framework for Cloudflare Workers with built-in server-side rendering, routing, caching, and a declarative client-side partial-update library.
4
4
 
5
- A tiny, zero-dependency toolkit that gives you:
5
+ Inspired by Hono, `@lengkapp/edge` aims for the same class of performance while shipping with zero runtime dependencies.
6
6
 
7
- - **`client.js`** — browser runtime driven by HTML attributes (`_get`, `_post`, `_go`, …)
8
- - **`server.js`** — Cloudflare Worker router with JSX, no bundler required
7
+ **Security:** The server is hardened against the OWASP Top 10:2025 and ASVS 5.0 Layer 1 controls — prototype-safe params, cookies, JSON bodies and JSX attributes; CSP-friendly response headers; fail-closed middleware; strict CORS allowlist; and structured security logging. See [Security](#security-controls).
9
8
 
10
- Small code, maximum security, no external runtime dependencies.
11
-
12
- ## Contents
9
+ ---
13
10
 
14
- 1. [Features](#1-features)
15
- 2. [Install](#2-install)
16
- 3. [Project Layout](#3-project-layout)
17
- 4. [Client Usage](#4-client-usage)
18
- 5. [Server Usage](#5-server-usage)
19
- 6. [Full Example (sample.tsx)](#6-full-example-sampletsx)
20
- 7. [Theming (Dark / Light)](#7-theming-dark--light)
21
- 8. [Icons (Inline Lucide)](#8-icons-inline-lucide-svg)
22
- 9. [Build](#9-build)
23
- 10. [Deploy (Wrangler)](#10-deploy-wrangler)
24
- 11. [Security](#11-security)
25
- 12. [License](#12-license)
11
+ ## Table of Contents
12
+
13
+ - [Features](#features)
14
+ - [Installation](#installation)
15
+ - [Quick Start](#quick-start)
16
+ - [Server API](#server-api)
17
+ - [JSX Support](#jsx-support)
18
+ - [Full Example](#full-example)
19
+ - [Client (Declarative Partial Updates)](#client-declarative-partial-updates)
20
+ - [Device Fingerprint](#device-fingerprint)
21
+ - [CSRF Protection](#csrf-protection)
22
+ - [Security Controls](#security-controls)
23
+ - [OWASP 2025 Coverage](#owasp-2025-coverage)
24
+ - [Scheduled Tasks](#scheduled-tasks)
25
+ - [Configuration](#configuration)
26
+ - [Performance](#performance)
27
+ - [Security Posture Summary](#security-posture-summary)
28
+ - [License](#license)
26
29
 
27
30
  ---
28
31
 
29
- ## 1. Features
30
-
31
- ### `client.js` (browser)
32
-
33
- | Attribute | Description |
34
- |------------|--------------|
35
- | `_get` | `fetch(url)` GET |
36
- | `_post` | `fetch(url)` POST, needs `_form` or `_json` |
37
- | `_go` | Navigate |
38
- | `_open` | Open in new tab |
39
- | `_in` | Place response using `innerHTML` |
40
- | `_out` | Place response using `outerHTML` |
41
- | `_before` | Place response before target |
42
- | `_after` | Place response after target |
43
- | `_form` | FormData source selector (for `_post`) |
44
- | `_json` | JSON source selector list (for `_post`) |
45
- | `_trigger` | `"click"` \| `"load"` \| `"visible"` (default `"click"`) |
46
- | `_id` | Send `X-DeviceId` header (GET/POST only) |
47
-
48
- **Automatic behavior:**
49
-
50
- - Always sends `X-CSRF-Token` from `<meta name="_csrf" content="...">`.
51
- - Sends `X-DeviceId` from `deviceId()` when `_id` is present.
52
- - `MutationObserver` re-attaches handlers to injected elements.
53
- - Loader + skeleton + retry/error UI injected around the target (cancelable via `AbortController` on repeat requests).
54
- - Uses `IntersectionObserver` for `_trigger="visible"`.
55
-
56
- ### `server.js` (Cloudflare Worker)
57
-
58
- - Hono-style router: `app.get` / `post` / `put` / `delete` / `options`
59
- - Path params: `"/:lang/:page"`
60
- - JSX without a bundler — works with `wrangler dev` / `deploy --minify`
61
- - Per-route config: `{ valid, auth }` for CSRF + cookie auth
62
- - Response helpers: `c.text`, `c.json`, `c.html`, `c.page`, `c.redirect`
63
- - CORS allowlist (exact hosts or `*.example.com`)
64
- - Extra response headers via `app.headers`
32
+ ## Features
33
+
34
+ ### Server
35
+
36
+ - **Trie-based routing** – static & dynamic routes (`/users/:id`)
37
+ - **JSX support** – pass JSX straight to `ctx.html(<Card />)`; no build step, no manual `renderToString` call
38
+ - **Middleware** – CORS, logging, caching, compression, validation
39
+ - **Cookie helpers** with validation
40
+ - **Scheduled tasks** via Cron triggers
41
+ - **Zero dependencies**
42
+
43
+ ### Client
44
+
45
+ - **Declarative partial updates** via `_get` / `_post` and the placement modes `_in`, `_out`, `_before`, `_after`
46
+ - **Client-side navigation** via `_go` (same tab) and `_open` (new tab) — no fetch, no loader
47
+ - **Opt-in device fingerprint** via `_id` — Canvas, WebGL, Audio, font probe, and basic navigator signals, hashed once per page; sent as `X-DeviceId` on `_get` / `_post` and as `?_did=` on `_go` / `_open`
48
+ - **Event, load, and visibility triggers** – `click` (default), `load`, `visible`, or any DOM event name
49
+ - **JSON and form bodies** – `_json="a,b,c"` or `_form="#signup"`
50
+ - **Scalable Translation** – `_translate={indentifier}`, based on `html lang={lang-id}` it will look for `/t/{lang-id}/{identifier}.jon`
51
+ - **Built-in loading and error states** – deferred spinner, skeleton loader, abortable requests, one-click retry
52
+ - **View Transitions aware** – swaps run inside `document.startViewTransition` when available
53
+ - **Zero dependencies**
54
+
55
+ ### Security
56
+
57
+ - **Prototype-pollution safe** – route params, cookies, JSON bodies, JSX attributes
58
+ - **XSS-hardened JSX** – no `on*` attributes, no `javascript:` URLs, no malformed tag names
59
+ - **Structured security logging** – throttled JSON events for validation and handler failures
60
+ - **Fail-closed middleware** – validation and handler errors deny by default
61
+ - **Strict CORS allowlist** – per-origin reflection with `Vary: Origin`
65
62
 
66
63
  ---
67
64
 
68
- ## 2. Install
65
+ ## Installation
69
66
 
70
67
  ```bash
71
- git clone <this repo>
72
- cd edge-libraries
73
- npm install
74
- npm run build # → dist/
75
- npm run dev #trying sample.tsx
68
+ npm install @lengkapp/edge
76
69
  ```
77
70
 
78
- Requires Node 18+ (built and tested on Node 20+).
71
+ ## Quick Start
79
72
 
80
- ---
73
+ `worker.js`:
74
+
75
+ ```ts
76
+ import { Edge } from '@lengkapp/edge';
77
+
78
+ const app = new Edge();
81
79
 
82
- ## 3. Project Layout
80
+ app.get('/', (ctx) => ctx.text('Hello World!'));
81
+ app.get('/users/:id', (ctx) => ctx.json({ id: ctx.params.id }));
83
82
 
83
+ export default app;
84
84
  ```
85
- client.js browser runtime (source)
86
- client.d.ts typings for the client directives
87
- server.js worker runtime + JSX factory (source)
88
- server.d.ts typings for Edge, Context, JSX
89
- sample.tsx full demo (routing, languages, theme toggle)
90
- build.mjs esbuild + terser bundler → dist/
91
- package.json dev deps: esbuild, terser
92
- tsconfig.json JSX: react / jsxFactory: jsx / jsxFragmentFactory: Fragment
93
- LICENSE MIT
94
-
95
- dist/ build output
96
- client.js minified + banner
97
- server.js minified + banner
98
- client.d.ts
99
- server.d.ts
100
- LICENSE
85
+
86
+ Or, if you will use JSX, `worker.tsx`:
87
+
88
+ ```tsx
89
+ import { Edge, jsx, Fragment } from '@lengkapp/edge';
90
+
91
+ const app = new Edge();
92
+
93
+ const Card = () => (
94
+ <div>card</div>
95
+ );
96
+
97
+ const LandingPage = () => (
98
+ <>
99
+ <h1>hello world</h1>
100
+ <Card />
101
+ </>
102
+ );
103
+
104
+ app.get('/', () => <LandingPage />);
105
+
106
+ export default app;
101
107
  ```
102
108
 
103
- ---
109
+ If you use TypeScript, `tsconfig.json`:
110
+
111
+ ```json
112
+ {
113
+ "compilerOptions": {
114
+ "jsx": "react",
115
+ "jsxFactory": "jsx",
116
+ "jsxFragmentFactory": "Fragment",
117
+ "paths": { "@/*": ["./src/*"] },
118
+ "types": ["@cloudflare/workers-types"],
119
+ "target": "ESNext",
120
+ "module": "ESNext",
121
+ "moduleResolution": "Bundler",
122
+ "strict": true,
123
+ "skipLibCheck": true,
124
+ "lib": ["ESNext", "WebWorker"]
125
+ }
126
+ }
127
+ ```
104
128
 
105
- ## 4. Client Usage
129
+ `wrangler.jsonc`:
106
130
 
107
- ```html
108
- <meta name="_csrf" content="...">
131
+ ```jsonc
132
+ {
133
+ "$schema": "./node_modules/wrangler/config-schema.json",
134
+ "name": "my-edge-app",
135
+ "main": "worker.js",
136
+ "compatibility_date": "2026-09-06"
137
+ }
138
+ ```
109
139
 
110
- <div id="page">loading</div>
140
+ Or `wrangler.toml`:
111
141
 
112
- <!-- GET, replace inner HTML of #page, fire on click -->
113
- <div _get="/data" _in="#page" _trigger="click">Load</div>
142
+ ```toml
143
+ name = "my-edge-app"
144
+ main = "worker.js"
145
+ compatibility_date = "2026-09-14"
146
+ ```
114
147
 
115
- <!-- GET on page load, send X-DeviceId -->
116
- <div _get="/stats" _in="#stats" _trigger="load" _id="1"></div>
148
+ Deploy:
117
149
 
118
- <!-- GET only when scrolled into view -->
119
- <div _get="/more" _in="#list" _trigger="visible"></div>
150
+ ```bash
151
+ wrangler deploy
152
+ ```
120
153
 
121
- <form id="myForm" onSubmit="return false;">
122
- <input type="text" id="emailInput" name="email" />
123
- <input type="password" id="passwordInput" name="password" />
124
- </form>
154
+ ## Server API
155
+
156
+ ### Context
157
+
158
+ | Member | Description |
159
+ |---|---|
160
+ | `ctx.req` | Incoming Request |
161
+ | `ctx.env` | Environment bindings |
162
+ | `ctx.executionCtx` | ExecutionContext |
163
+ | `ctx.params` | Prototype-safe route params object |
164
+ | `ctx.status` | Default response status (200) |
165
+ | `ctx.headers` | Response Headers |
166
+ | `ctx.query` | URLSearchParams |
167
+ | `ctx.url` | Parsed URL object |
168
+ | `ctx.getCookie(name)` | Read a cookie |
169
+ | `ctx.setCookie(name, value, options)` | Set a cookie (name validated) |
170
+ | `ctx.deleteCookie(name, options)` | Delete a cookie |
171
+ | `ctx.text(data, status?, headers?)` | Plain-text response |
172
+ | `ctx.json(data, status?, headers?)` | JSON response |
173
+ | `ctx.html(data, status?, headers?)` | HTML response — accepts a raw string, a JSX element, or an array of JSX elements |
174
+ | `ctx.redirect(location, status?)` | Redirect (default 302), preserving headers already set on the context |
175
+
176
+ Every response carries `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, and a restrictive `Permissions-Policy`.
177
+
178
+ ### Route Options
179
+
180
+ ```ts
181
+ app.get('/cached', { cache: { ttl: 60 } }, handler);
182
+ app.get('/api', { cors: true }, handler);
183
+ app.get('/gzip', { compress: true }, handler);
184
+ app.get('/logged', { log: true }, handler);
185
+ app.post('/submit', { validate: (ctx) => /* ... */ true }, handler);
186
+ ```
187
+
188
+ **Cache:** `{ ttl, staleWhileRevalidate }` — TTL in seconds, default 3600; only applied to GET responses with status 200. `Set-Cookie` is stripped from cached responses.
189
+
190
+ **CORS:** The default is `origin: '*'`. For production, use a strict allowlist:
191
+
192
+ ```ts
193
+ app.defaults.cors.origin = ['https://app.example.com'];
194
+ ```
195
+
196
+ **Compress:** Negotiates gzip or deflate from `Accept-Encoding` using the native `CompressionStream`.
197
+
198
+ **Log:** Logs `METHOD URL - STATUS` to the console.
125
199
 
126
- <!-- POST multipart/form-data from #myForm -->
127
- <div _post="/by-form" _in="#page" _trigger="click" _form="#myForm">
128
- Submit (FORM)
129
- </div>
200
+ **Validate:** Receives the Context; return `true` to allow or `false` / a falsy value to reject with `400 Validation failed`. Async validators are awaited. Thrown errors are logged and treated as a rejection.
130
201
 
131
- <!-- POST application/json from selected inputs -->
132
- <div _post="/by-json" _in="#page" _trigger="click"
133
- _json="#emailInput,#passwordInput">
134
- Submit (JSON)
135
- </div>
202
+ ## JSX Support
136
203
 
137
- <!-- Navigate / new tab -->
138
- <div _go="/home" _trigger="click">Go home</div>
139
- <div _open="https://example.com" _trigger="click">Open example</div>
204
+ `ctx.html()` accepts JSX directly — it detects JSX nodes and arrays and renders them automatically. Raw strings are passed through untouched, so you can still serve pre-rendered HTML.
140
205
 
141
- <!-- Placement variants -->
142
- <div _get="/x" _in="#target"> innerHTML </div>
143
- <div _get="/x" _out="#target"> outerHTML </div>
144
- <div _get="/x" _before="#target"> before </div>
145
- <div _get="/x" _after="#target"> after </div>
206
+ ```tsx
207
+ function Card({ title }) {
208
+ return <div class="card"><h2>{title}</h2></div>;
209
+ }
210
+
211
+ // Pass JSX straight to ctx.html — no manual renderToString needed.
212
+ app.get('/card', (ctx) => ctx.html(<Card title="Hello" />));
213
+ app.get('/heading', (ctx) => ctx.html(<h1>hello</h1>));
214
+ app.get('/list', (ctx) => ctx.html([<Card title="A" />, <Card title="B" />]));
146
215
 
147
- <script src="/client.js"></script>
216
+ // Raw strings still work as before.
217
+ app.get('/raw', (ctx) => ctx.html('<p>pre-rendered</p>'));
148
218
  ```
149
219
 
150
- Every element with a directive is auto-initialized, including elements added later to the DOM.
220
+ Returning a JSX element directly from a handler is also supported — it is treated as an HTML response:
151
221
 
152
- ---
222
+ ```tsx
223
+ app.get('/', () => <LandingPage />);
224
+ ```
153
225
 
154
- ## 5. Server Usage
226
+ `renderToString` is still exported for advanced use cases (for example, embedding rendered HTML inside another response body or email template):
155
227
 
156
228
  ```tsx
157
- import { Edge, jsx, Fragment } from "./dist/server.js";
229
+ import { renderToString } from '@lengkapp/edge';
158
230
 
159
- const app = new Edge();
231
+ const html = renderToString(<Card title="Hello" />);
232
+ ```
160
233
 
161
- app.cors = ["abc.com", "*.cde.com"]; // ["*"] by default
162
- app.headers = { "x-powered-by": "edge" }; // extra response headers
234
+ **`renderToString` hardening:**
163
235
 
164
- const config = {
165
- valid: false, // require cookie _csrf == X-CSRF-Token
166
- auth: false, // require cookie _auth
167
- };
236
+ - Tag names must match `^[A-Za-z][A-Za-z0-9-]*$`.
237
+ - Attribute names must match `^[A-Za-z_:][A-Za-z0-9_:.-]*$`.
238
+ - `on*` attributes never serialize.
239
+ - `href` / `src` / `action` / `formaction` / `xlink:href` values beginning with `javascript:`, `vbscript:`, or `data:text/html` are stripped.
240
+ - Prototype keys (`__proto__`, `constructor`, `prototype`) are rejected.
241
+ - All string values are HTML-escaped.
242
+ - **Style objects.** `style={{ backgroundColor: 'tomato', padding: 12 }}` is emitted as `style="background-color:tomato;padding:12px"`. Numeric values are suffixed with `px` unless the property is unitless (`opacity`, `lineHeight`, `zIndex`, `flex`, …).
243
+ - **Aliases.** `className` → `class`, `htmlFor` → `for`.
244
+ - **Boolean attributes.** `checked`, `disabled`, `required`, `readonly`, `multiple`, etc. emit as bare attributes when `true` and are dropped when `false`.
245
+ - **`dangerouslySetInnerHTML`.** Supported via `dangerouslySetInnerHTML={{ __html: '…' }}` — the value is inserted verbatim and is not escaped. Only use it with trusted content.
246
+
247
+ ## Full Example
168
248
 
169
- app.get("/", config, (c) => {
170
- return c.page(
171
- <html>
172
- <body><h1>Hello Edge</h1></body>
249
+ A single file that exercises every server feature.
250
+
251
+ ```tsx
252
+ // sample.tsx
253
+ //
254
+ // Demonstrates every feature of @lengkapp/edge:
255
+ // - static & dynamic routes, all HTTP methods
256
+ // - params, query, cookies (get/set/delete)
257
+ // - ctx.text / ctx.json / ctx.html / ctx.redirect
258
+ // - JSX rendering (elements, Fragments, function components, arrays)
259
+ // - style objects, boolean attributes, void elements,
260
+ // className/htmlFor aliases, dangerouslySetInnerHTML
261
+ // - route options: cors, cache, compress, log, validate
262
+ // - security.extraHeaders, security.logSecurityEvents
263
+ // - scheduled handler
264
+ // - returning JSX directly from a handler
265
+
266
+ import {
267
+ Edge,
268
+ Context,
269
+ Fragment,
270
+ renderToString,
271
+ type JSXNode,
272
+ type RouteOptions,
273
+ } from '@lengkapp/edge';
274
+
275
+ /* ------------------------------------------------------------------ *
276
+ * Small helper components (JSX function components) *
277
+ * ------------------------------------------------------------------ */
278
+
279
+ function Layout(props: { title: string; children?: any }) {
280
+ return (
281
+ <html lang="en">
282
+ <head>
283
+ <meta charset="utf-8" />
284
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
285
+ <title>{props.title}</title>
286
+ </head>
287
+ <body>
288
+ <header>
289
+ <nav>
290
+ <a href="/">Home</a>{' · '}
291
+ <a href="/about">About</a>{' · '}
292
+ <a href="/users/42">User 42</a>{' · '}
293
+ <a href="/dashboard">Dashboard</a>
294
+ </nav>
295
+ </header>
296
+ <main>{props.children}</main>
297
+ <footer>© {new Date().getFullYear()}</footer>
298
+ </body>
173
299
  </html>
174
300
  );
301
+ }
302
+
303
+ function UserCard(props: { id: string; name: string; admin?: boolean }) {
304
+ return (
305
+ <div class="card" data-id={props.id}>
306
+ <h2>{props.name}</h2>
307
+ {props.admin && <span class="badge">admin</span>}
308
+ </div>
309
+ );
310
+ }
311
+
312
+ function TodoList(props: { items: string[] }) {
313
+ return (
314
+ <ul>
315
+ {props.items.map((item, i) => (
316
+ <li key={i}>{item}</li>
317
+ ))}
318
+ </ul>
319
+ );
320
+ }
321
+
322
+ /* ------------------------------------------------------------------ *
323
+ * App *
324
+ * ------------------------------------------------------------------ */
325
+
326
+ const app = new Edge();
327
+
328
+ // ---- Security: global extra headers + keep security logging on ------
329
+ app.security.logSecurityEvents = true;
330
+ app.security.extraHeaders = {
331
+ 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
332
+ 'X-Custom-Powered-By': 'edge-server',
333
+ };
334
+
335
+ /* ================================================================== *
336
+ * Basic routes *
337
+ * ================================================================== */
338
+
339
+ // Plain text
340
+ app.get('/health', (ctx) => ctx.text('ok'));
341
+
342
+ // JSON with a custom status
343
+ app.get('/api/time', (ctx) =>
344
+ ctx.json({ now: new Date().toISOString() }, 200)
345
+ );
346
+
347
+ // Returning JSX directly from a handler → automatically becomes
348
+ // a text/html Response.
349
+ app.get('/', () => (
350
+ <Layout title="Home">
351
+ <h1>Hello from edge-server</h1>
352
+ <p>This page was rendered from JSX.</p>
353
+ <TodoList items={['Write routes', 'Render JSX', 'Ship it']} />
354
+ </Layout>
355
+ ));
356
+
357
+ // Explicit ctx.html with a JSX tree
358
+ app.get('/about', (ctx) =>
359
+ ctx.html(
360
+ <Layout title="About">
361
+ <h1>About</h1>
362
+ <p>
363
+ Fragments, components, arrays — all supported.
364
+ </p>
365
+ {/* Array of JSX is allowed inside a fragment */}
366
+ <Fragment>
367
+ <UserCard id="1" name="Ada" admin />
368
+ <UserCard id="2" name="Grace" />
369
+ </Fragment>
370
+ </Layout>
371
+ )
372
+ );
373
+
374
+ // ctx.html also accepts a raw HTML string (passes through unchanged)
375
+ app.get('/raw', (ctx) =>
376
+ ctx.html('<h1>Raw HTML</h1><p>Not escaped.</p>')
377
+ );
378
+
379
+ /* ================================================================== *
380
+ * Params, query, cookies *
381
+ * ================================================================== */
382
+
383
+ // Dynamic route: /users/:id
384
+ app.get('/users/:id', (ctx) => {
385
+ const { id } = ctx.params;
386
+ return ctx.html(
387
+ <Layout title={`User ${id}`}>
388
+ <UserCard id={id} name={`User #${id}`} />
389
+ </Layout>
390
+ );
175
391
  });
176
392
 
177
- app.get("/:lang/:page", config, (c) => {
178
- return c.html(
179
- <h1>{c.req.param("lang")} / {c.req.param("page")}</h1>
393
+ // Multiple params: /posts/:year/:slug
394
+ app.get('/posts/:year/:slug', (ctx) => {
395
+ const { year, slug } = ctx.params;
396
+ return ctx.json({ year, slug });
397
+ });
398
+
399
+ // Query strings: /search?q=hello&limit=10
400
+ app.get('/search', (ctx) => {
401
+ const q = ctx.query.get('q') ?? '';
402
+ const limit = Number(ctx.query.get('limit') ?? '10');
403
+ return ctx.json({ q, limit });
404
+ });
405
+
406
+ // Cookies: read, write, delete
407
+ app.get('/login', (ctx) => {
408
+ ctx.setCookie('session', 'abc123', {
409
+ path: '/',
410
+ httpOnly: true,
411
+ secure: true,
412
+ sameSite: 'Lax',
413
+ maxAge: 3600,
414
+ });
415
+ return ctx.redirect('/dashboard');
416
+ });
417
+
418
+ app.get('/logout', (ctx) => {
419
+ ctx.deleteCookie('session', { path: '/' });
420
+ return ctx.redirect('/');
421
+ });
422
+
423
+ app.get('/dashboard', (ctx) => {
424
+ const session = ctx.getCookie('session');
425
+ if (!session) return ctx.redirect('/login');
426
+ return ctx.html(
427
+ <Layout title="Dashboard">
428
+ <h1>Dashboard</h1>
429
+ <p>Session: {session}</p>
430
+ </Layout>
180
431
  );
181
432
  });
182
433
 
183
- app.post("/api/save", async (c) => {
184
- const body = await c.req.json();
185
- return c.json({ ok: true, body });
434
+ /* ================================================================== *
435
+ * All HTTP methods *
436
+ * ================================================================== */
437
+
438
+ app.post('/api/echo', async (ctx) => {
439
+ const body = await ctx.req.json().catch(() => null);
440
+ return ctx.json({ received: body }, 201);
186
441
  });
187
442
 
188
- app.get("/old", (c) => c.redirect("/new", 301));
443
+ app.put('/api/items/:id', async (ctx) => {
444
+ const body = await ctx.req.json().catch(() => null);
445
+ return ctx.json({ updated: ctx.params.id, body });
446
+ });
189
447
 
190
- export default {
191
- fetch: (request, env, ctx) => app.fetch(request, env, ctx),
448
+ app.patch('/api/items/:id', (ctx) =>
449
+ ctx.json({ patched: ctx.params.id })
450
+ );
451
+
452
+ app.delete('/api/items/:id', (ctx) =>
453
+ ctx.json({ deleted: ctx.params.id }, 200)
454
+ );
455
+
456
+ app.options('/api/items', (ctx) => ctx.text('', 204));
457
+
458
+ app.head('/api/items', (ctx) => ctx.text('', 200));
459
+
460
+ /* ================================================================== *
461
+ * Route options: cors, cache, compress, log, validate *
462
+ * ================================================================== */
463
+
464
+ // CORS with a wildcard origin
465
+ app.get(
466
+ '/cors-open',
467
+ { cors: true, log: true },
468
+ (ctx) => ctx.json({ cors: 'wildcard' })
469
+ );
470
+
471
+ // CORS with an allow-list + credentials
472
+ app.get(
473
+ '/cors-restricted',
474
+ {
475
+ cors: {
476
+ origin: ['https://app.example.com', 'https://admin.example.com'],
477
+ methods: 'GET, POST',
478
+ headers: 'Content-Type, X-CSRF-Token',
479
+ },
480
+ },
481
+ (ctx) => ctx.json({ cors: 'restricted' })
482
+ );
483
+
484
+ // Caching: cache the GET response for 60s, revalidate in background
485
+ app.get(
486
+ '/cached',
487
+ {
488
+ cache: { ttl: 60, staleWhileRevalidate: 30 },
489
+ log: true,
490
+ },
491
+ (ctx) => ctx.json({ generatedAt: Date.now() })
492
+ );
493
+
494
+ // Compression (gzip / deflate based on Accept-Encoding)
495
+ app.get(
496
+ '/big',
497
+ { compress: true },
498
+ (ctx) => ctx.html(`<pre>${'x'.repeat(5000)}</pre>`)
499
+ );
500
+
501
+ // Request validation — return false to get a 400 automatically
502
+ app.post(
503
+ '/admin',
504
+ {
505
+ validate: (ctx) => {
506
+ const token = ctx.req.headers.get('X-Admin-Token');
507
+ return token === 'let-me-in';
508
+ },
509
+ },
510
+ (ctx) => ctx.json({ ok: true })
511
+ );
512
+
513
+ // Everything combined
514
+ const everythingOptions: RouteOptions = {
515
+ cors: { origin: '*' },
516
+ cache: { ttl: 120, staleWhileRevalidate: 60 },
517
+ compress: true,
518
+ log: true,
519
+ validate: async (ctx) => ctx.req.method === 'GET',
192
520
  };
193
- ```
194
521
 
195
- **Response helpers:**
196
-
197
- | Helper | Behavior |
198
- |--------|----------|
199
- | `c.text(str, status?)` | `text/plain; charset=utf-8` |
200
- | `c.json(obj \| str, status?)` | `application/json; charset=utf-8` |
201
- | `c.html(str, status?)` | `text/html; charset=utf-8` |
202
- | `c.page(str, status?)` | `text/html`, prepends `<!DOCTYPE html>` if needed |
203
- | `c.redirect(url, 301 \| 302)` | default `302` |
204
-
205
- **Request helpers on `c.req`:**
206
-
207
- | Helper | Description |
208
- |--------|-------------|
209
- | `.param(name)` | path params |
210
- | `.query(name)` | URL query |
211
- | `.header(name)` | request header (lowercase) |
212
- | `.cookie(name)` | parsed cookie value |
213
- | `.json()` | parse JSON body |
214
- | `.text()` | parse text body |
215
- | `.formData()` | parse form body |
216
- | `.raw` | original `Request` |
217
- | `.url` | full URL string |
218
- | `.method` | HTTP verb |
219
-
220
- `c.env` is your Worker bindings. `c.ctx` is the Worker `ExecutionContext`.
522
+ app.get('/everything', everythingOptions, (ctx) =>
523
+ ctx.html(
524
+ <Layout title="Everything">
525
+ <h1>All options at once</h1>
526
+ </Layout>
527
+ )
528
+ );
529
+
530
+ /* ================================================================== *
531
+ * JSX feature gallery *
532
+ * ================================================================== */
533
+
534
+ app.get('/jsx/gallery', (ctx) =>
535
+ ctx.html(
536
+ <Layout title="JSX Gallery">
537
+ {/* Style objects → kebab-cased, numbers get px added */}
538
+ <div
539
+ style={{
540
+ backgroundColor: 'tomato',
541
+ padding: 12,
542
+ opacity: 0.9,
543
+ lineHeight: 1.4, // unitless, stays as-is
544
+ }}
545
+ >
546
+ Styled box
547
+ </div>
548
+
549
+ {/* className and htmlFor are aliased to class / for */}
550
+ <label className="lbl" htmlFor="name">
551
+ Name
552
+ </label>
553
+ <input id="name" type="text" required disabled={false} />
554
+
555
+ {/* Boolean attributes: true emits the bare attribute */}
556
+ <input type="checkbox" checked readOnly />
557
+ <button disabled>Nope</button>
558
+
559
+ {/* Void elements self-close */}
560
+ <img src="/logo.png" alt="logo" />
561
+ <br />
562
+ <hr />
563
+
564
+ {/* dangerouslySetInnerHTML */}
565
+ <div dangerouslySetInnerHTML={{ __html: '<b>trusted</b>' }} />
566
+
567
+ {/* Fragments */}
568
+ <>
569
+ <p>Fragment child A</p>
570
+ <p>Fragment child B</p>
571
+ </>
572
+
573
+ {/* Arrays of JSX */}
574
+ {[<span key="a">A</span>, <span key="b">B</span>, <span key="c">C</span>]}
575
+
576
+ {/* Escaping: user-supplied strings are escaped */}
577
+ <p>{'<script>alert(1)</script>'}</p>
578
+
579
+ {/* Dangerous URLs are dropped */}
580
+ <a href="javascript:alert(1)">nope</a>
581
+ <a href="https://example.com">ok</a>
582
+
583
+ {/* Numbers are stringified and escaped */}
584
+ <p>Count: {42}</p>
585
+
586
+ {/* null / undefined / booleans render nothing */}
587
+ <p>{null}{undefined}{false}{true}</p>
588
+ </Layout>
589
+ )
590
+ );
591
+
592
+ /* ================================================================== *
593
+ * renderToString() standalone *
594
+ * ================================================================== */
595
+
596
+ app.get('/jsx/string', (ctx) => {
597
+ const html = renderToString(
598
+ <section>
599
+ <h1>Rendered manually</h1>
600
+ <p>Via renderToString()</p>
601
+ </section>
602
+ );
603
+ return ctx.html(html);
604
+ });
221
605
 
222
- ---
606
+ /* ================================================================== *
607
+ * Scheduled handler *
608
+ * ================================================================== */
223
609
 
224
- ## 6. Full Example (sample.tsx)
610
+ app.scheduled(async (event, env, ctx) => {
611
+ console.log('cron fired at', new Date(event.scheduledTime).toISOString());
612
+ // e.g. warm a cache, prune KV entries, etc.
613
+ });
225
614
 
226
- `sample.tsx` demonstrates:
615
+ /* ================================================================== *
616
+ * Cloudflare Workers entry points *
617
+ * ================================================================== */
227
618
 
228
- - Cookie-aware language routing (path → cookie → default)
229
- - Root `/` redirect to `/:lang/welcome`
230
- - Full localized page in the selected script
231
- - Client-driven partials via `_get` + `_in` + `_trigger`
232
- - `_trigger="load"` (fire on page load)
233
- - `_trigger="visible"` (`IntersectionObserver`)
234
- - `_id` → `X-DeviceId` header from `deviceId()` (returns `new Date().toString()`)
235
- - `_form` and `_json` submissions to two separate endpoints
236
- - `_go` navigation between language routes
237
- - `_open` → new tab
238
- - Placement variants (`_in`, `_out`, `_before`, `_after`)
239
- - Dark / light theme toggle (persisted, respects `prefers-color-scheme`)
240
- - Chakra Petch typography via Google Fonts
241
- - Inline lucide SVG icons (no CDN, no images)
619
+ export default {
620
+ fetch: (req: Request, env: any, ctx: ExecutionContext) =>
621
+ app.fetch(req, env, ctx),
622
+ scheduled: (event: ScheduledEvent, env: any, ctx: ExecutionContext) =>
623
+ app.scheduledHandler?.(event, env, ctx),
624
+ };
625
+ ```
242
626
 
243
- Run it locally:
627
+ ### Feature → route cheat-sheet
628
+
629
+ | Feature | Route / location |
630
+ |---|---|
631
+ | `ctx.text` | `GET /health` |
632
+ | `ctx.json` | `GET /api/time`, `POST /api/echo`, … |
633
+ | `ctx.html` with JSX | `GET /` |
634
+ | `ctx.html` with string | `GET /raw` |
635
+ | Returning JSX directly | `GET /` |
636
+ | `ctx.redirect` | `GET /login`, `GET /logout`, `GET /dashboard` |
637
+ | `ctx.params` | `GET /users/:id`, `GET /posts/:year/:slug` |
638
+ | `ctx.query` | `GET /search` |
639
+ | `ctx.getCookie` / `setCookie` / `deleteCookie` | `/login`, `/logout`, `/dashboard` |
640
+ | All HTTP verbs | `/api/echo` (POST), `/api/items/:id` (PUT/PATCH/DELETE), `/api/items` (OPTIONS/HEAD) |
641
+ | `cors` | `/cors-open`, `/cors-restricted`, `/everything` |
642
+ | `cache` | `/cached`, `/everything` |
643
+ | `compress` | `/big`, `/everything` |
644
+ | `log` | `/cors-open`, `/cached`, `/everything` |
645
+ | `validate` | `POST /admin`, `/everything` |
646
+ | `security.extraHeaders` | set once near the top |
647
+ | `security.logSecurityEvents` | set once near the top |
648
+ | Fragments | `GET /about`, `GET /jsx/gallery` |
649
+ | Function components | `Layout`, `UserCard`, `TodoList` |
650
+ | Style objects | `GET /jsx/gallery` |
651
+ | Boolean attrs / void elements | `GET /jsx/gallery` |
652
+ | `dangerouslySetInnerHTML` | `GET /jsx/gallery` |
653
+ | `renderToString()` standalone | `GET /jsx/string` |
654
+ | `scheduled()` | bottom of file |
655
+
656
+ ## Client (Declarative Partial Updates)
244
657
 
245
- ```bash
246
- npm run build
247
- npx wrangler dev sample.tsx
658
+ ```html
659
+ <script src="https://cdn.example.com/edge-client.min.js"></script>
248
660
  ```
249
661
 
250
- ---
662
+ ### Attributes
251
663
 
252
- ## 7. Theming (Dark / Light)
664
+ | Attribute | Description |
665
+ |---|---|
666
+ | `_get` / `_post` | Request URL and HTTP method. Only GET and POST are supported. |
667
+ | `_go` | Navigate the current tab to the URL (`location.assign`). |
668
+ | `_open` | Open the URL in a new tab (`noopener,noreferrer`). |
669
+ | `_id` | Opt in to the device fingerprint for this element. See [Device Fingerprint](#device-fingerprint). |
670
+ | `_in` | Replace the target's children with the response. |
671
+ | `_out` | Replace the target element itself. |
672
+ | `_before` | Insert the response before the target. |
673
+ | `_after` | Insert the response after the target. |
674
+ | `_toast` | Insert the response non 2xx to toast element if any. |
675
+ | `_trigger` | `click` (default), `load`, `visible`, or any DOM event name. |
676
+ | `_form` | CSS selector or element ID of a form to serialize as the body. |
677
+ | `_json` | Comma-separated field names to send as a JSON body. |
678
+ | `_loader` | `spinner` (default), `skeleton`, or `none` / `off` / `false` to disable. |
679
+ | `_timeout` | Request timeout in milliseconds (default 20000). |
253
680
 
254
- The page reads `data-theme` on `<html>` and drives every color through CSS custom properties. There are two token sets:
681
+ **Targets.** Exactly one placement attribute (`_in`, `_out`, `_before`, `_after`) should be present. Its value is a CSS selector, the literal string `this`, or empty — the last two both resolve to the element that carries the attribute.
255
682
 
256
- - `:root` → light
257
- - `:root[data-theme="dark"]` → dark
683
+ **Field scope.** `_json` reads values from the closest enclosing `<form>`, or from the document if there is none. It also accepts fields by `id` first, then by `name` (grouped radio/checkbox inputs are handled).
258
684
 
259
- Initialization runs synchronously in `<head>` before the first paint, so there is no flash of the wrong theme:
685
+ ### Examples
260
686
 
261
687
  ```html
262
- <script>
263
- (function () {
264
- try {
265
- var t = localStorage.getItem("theme");
266
- if (t !== "dark" && t !== "light") {
267
- t = window.matchMedia &&
268
- window.matchMedia("(prefers-color-scheme: dark)").matches
269
- ? "dark" : "light";
270
- }
271
- document.documentElement.setAttribute("data-theme", t);
272
- } catch (e) {
273
- document.documentElement.setAttribute("data-theme", "light");
274
- }
275
- })();
276
- </script>
277
- ```
688
+ <!-- replace the children of #posts with the response -->
689
+ <button _get="/more-posts" _in="#posts">Load More</button>
278
690
 
279
- The toggle button contains **both** the moon and the sun SVG. CSS shows only one:
691
+ <!-- replace this element with the response -->
692
+ <div _get="/user-profile" _out="this"></div>
280
693
 
281
- ```css
282
- :root[data-theme="light"] .icon-btn .icon-moon { display: inline-flex }
283
- :root[data-theme="dark"] .icon-btn .icon-sun { display: inline-flex }
284
- ```
694
+ <!-- POST JSON built from form fields, replace the children of #status -->
695
+ <button _post="/login" _json="username,password" _in="#status">Login</button>
285
696
 
286
- The click handler flips `data-theme`, writes to `localStorage`, and updates `aria-label`. When no preference is stored, the page follows the OS scheme.
697
+ <!-- POST a whole form -->
698
+ <form id="signup">…</form>
699
+ <button _post="/signup" _form="#signup" _in="#result">Sign up</button>
287
700
 
288
- All edge-client loader/error UI uses `currentColor`, so it inherits the active theme automatically.
701
+ <!-- fetch lazily when the element scrolls into view -->
702
+ <div _get="/lazy" _in="this" _trigger="visible"></div>
289
703
 
290
- ---
704
+ <!-- skeleton loader with a 5-second timeout -->
705
+ <div _get="/feed" _in="this" _loader="skeleton" _timeout="5000"></div>
706
+
707
+ <!-- _toast if non 2xx -->
708
+ <div _get="/feed-error" _in="this" _toast="#toast"></div>
291
709
 
292
- ## 8. Icons (Inline Lucide SVG)
710
+ <!-- same-tab navigation with a device id -->
711
+ <a href="/dashboard" _go _id>Dashboard</a>
293
712
 
294
- Every icon is an inline `<svg>` — no CDN, no `<img>`, no external requests.
713
+ <!-- new-tab navigation -->
714
+ <a href="/docs" _open>Docs</a>
295
715
 
296
- | Icon | Purpose |
297
- |------|---------|
298
- | `IconMoon` | theme (dark side) |
299
- | `IconSun` | theme (light side) |
300
- | `IconLanguages` | brand mark |
301
- | `IconRefresh` | refresh stats |
302
- | `IconZap` | device ping |
303
- | `IconExternal` | external link |
304
- | `IconHome` | go home |
305
- | `IconGlobe` | card header |
716
+ <!-- POST with X-DeviceId header -->
717
+ <button _post="/like" _id _in="#card-3">Like</button>
718
+ ```
306
719
 
307
- All icons use `stroke="currentColor"` and inherit their color from the surrounding element, so they follow the theme automatically.
720
+ ### Triggers
308
721
 
309
- ---
722
+ - `click` (default) — handled by a single delegated document listener.
723
+ - `load` / `visible` — the element is observed with `IntersectionObserver` (300px root margin) and the request fires the first time it enters the viewport.
724
+ - Any other value — treated as a DOM event name. The listener is attached the first time the element becomes visible, then fires normally.
310
725
 
311
- ## 9. Build
726
+ ### How Content Is Inserted
312
727
 
313
- ```bash
314
- npm run build
315
- ```
728
+ - The request is sent with `credentials: 'same-origin'` and `X-Requested-With: XMLHttpRequest`.
729
+ - When the triggering element carries `_id`, the request includes an `X-DeviceId` header (see [Device Fingerprint](#device-fingerprint)).
730
+ - For `_post`, the body is JSON (`_json`), a `FormData` object (`_form`), or empty.
731
+ - The response text is parsed into a `<template>`.
732
+ - Any `<script>` elements are lifted out, then re-created and appended to `<head>` so the browser executes them. External scripts are de-duplicated by absolute URL.
733
+ - Newly inserted `[_get]` / `[_post]` elements are scanned and bound.
734
+ - Placement depends on the target mode:
735
+ - `_in` — the target's existing children are removed, then the fragment is appended.
736
+ - `_out` — the target itself is replaced.
737
+ - `_before` / `_after` — the fragment is inserted adjacent to the target.
316
738
 
317
- `build.mjs`:
739
+ > **Note:** No content-type check, HTML sanitization, or CSRF token is applied by the client — it trusts the server's response and inserts it as-is. Treat the partial-HTML endpoints you point `_get` / `_post` at as part of your trusted surface.
318
740
 
319
- - **esbuild** — bundles and minifies `client.js` and `server.js` (target `es2022`, format `esm`). License banner injected via esbuild's `banner.js` option.
320
- - **terser** — second pass, toplevel mangle + 2 compress passes.
321
- - Copies `client.d.ts`, `server.d.ts`, `LICENSE` into `dist/`.
322
- - Gracefully falls back to an inline MIT stub if `LICENSE` is missing.
741
+ ### Loader & Error States
323
742
 
324
- **Output:**
743
+ - The loader is deferred by 100ms: if the response lands before then, no loader is shown at all.
744
+ - Once shown, the loader stays for at least 240ms before the content swaps in, so it never flashes.
745
+ - `_loader="spinner"` (default), `_loader="skeleton"` for a shimmering skeleton, or `_loader="none"` to disable.
746
+ - A new request on the same element aborts the previous one via `AbortController`.
747
+ - On failure — non-2xx, network error, or `_timeout` — the loader is replaced in place by an error box with a retry button that re-issues the request.
748
+ - Loaders and error states use `role="status"` / `role="alert"` with `aria-busy` set on the target while in flight.
325
749
 
326
- ```
327
- dist/client.js minified + /*! MIT */ banner
328
- dist/server.js minified + /*! MIT */ banner
329
- dist/client.d.ts
330
- dist/server.d.ts
331
- dist/LICENSE
332
- ```
750
+ ### View Transitions
333
751
 
334
- ---
752
+ When `document.startViewTransition` is available, loader → content and loader → error swaps are wrapped in a view transition. The injected stylesheet disables the animation under `prefers-reduced-motion: reduce`.
335
753
 
336
- ## 10. Deploy (Wrangler)
754
+ ## Device Fingerprint
337
755
 
338
- `wrangler.toml`:
756
+ Add `_id` to any `_get` / `_post` / `_go` / `_open` element to attach a stable device identifier.
339
757
 
340
- ```toml
341
- name = "edge-translations"
342
- main = "sample.tsx"
343
- compatibility_date = "2026-01-01"
758
+ **Signals.** `navigator.userAgent`, `navigator.language`, screen dimensions, color depth, timezone offset, `hardwareConcurrency`, `deviceMemory`, plus Canvas, WebGL renderer, an offline Audio context, and a 10-font width probe. Combined and SHA-256 hashed.
344
759
 
345
- [[rules]]
346
- type = "Text"
347
- globs = ["dist/client.js"]
348
- ```
760
+ **Cost.** Computed lazily once per page and cached for the document lifetime; warmed at boot if any `[_id]` element is in the DOM. Adds ~0.9 KB gzipped to the client bundle and a few milliseconds on the first call.
349
761
 
350
- In `sample.tsx`, import the built `client.js` as text and expose it via `env`:
762
+ **Delivery.**
351
763
 
352
- ```tsx
353
- // @ts-ignore
354
- import clientJs from "./dist/client.js";
764
+ - `_get` / `_post` → sent as the `X-DeviceId` request header (no body, no URL change).
765
+ - `_go` / `_open` → appended to the URL as `?_did=<hash>` (headers cannot be set on `location.assign` / `window.open`).
355
766
 
356
- export default {
357
- fetch: (request, env, ctx) => {
358
- env.CLIENT_JS ??= clientJs;
359
- return app.fetch(request, env, ctx);
360
- },
361
- };
362
- ```
767
+ **Fallback.** If `crypto.subtle` is unavailable (non-secure context), a djb2 hash of the same signals is used, with lower collision resistance.
363
768
 
364
- Alternatively, serve `dist/client.js` as a static asset and drop the `/client.js` route from the app.
769
+ Without `_id`, no fingerprint is computed, no header is set, and no URL parameter is added.
365
770
 
366
- **Commands:**
771
+ ## CSRF Protection
367
772
 
368
- ```bash
369
- npx wrangler dev sample.tsx # local dev
370
- npx wrangler deploy sample.tsx --minify # production
371
- ```
773
+ The client does not attach a CSRF token automatically. Include the token as a form field or as part of the JSON payload built by `_json`, then validate it on the server with a `validate` option:
372
774
 
373
- ---
775
+ ```ts
776
+ app.post('/submit', {
777
+ validate: async (ctx) => {
778
+ const body = await ctx.req.json().catch(() => ({}));
779
+ return body.csrf_token && body.csrf_token === ctx.getCookie('csrf_token');
780
+ }
781
+ }, handler);
782
+ ```
374
783
 
375
- ## 11. Security
784
+ For `_form`-based submissions, read the field from the parsed form data using the same pattern.
376
785
 
377
- **CSRF**
378
- The client always sends `X-CSRF-Token` read from `<meta name="_csrf" content="...">`. Enable per-route verification with `{ valid: true }`. The server then requires cookie `_csrf == X-CSRF-Token`.
786
+ ## Security Controls
379
787
 
380
- **Auth**
381
- Enable per-route verification with `{ auth: true }`. The server requires a cookie named `_auth`.
788
+ ```ts
789
+ const app = new Edge();
382
790
 
383
- **Device identity**
384
- Elements with `_id="1"` add an `X-DeviceId` header. The default `deviceId()` implementation returns `new Date().toString()`; override it globally:
791
+ app.defaults.cors.origin = ['https://app.example.com'];
385
792
 
386
- ```js
387
- window.deviceId = () => myStableFingerprint();
793
+ app.security.logSecurityEvents = true; // structured JSON events (default on)
794
+ app.security.extraHeaders = {
795
+ 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
796
+ 'Cross-Origin-Opener-Policy': 'same-origin',
797
+ 'Cross-Origin-Resource-Policy': 'same-origin',
798
+ };
388
799
  ```
389
800
 
390
- **CORS**
801
+ ## OWASP 2025 Coverage
802
+
803
+ | Category | Mitigation |
804
+ |---|---|
805
+ | A01 Broken Access Control | Strict CORS allowlist, strict target resolution, no implicit trust |
806
+ | A02 Security Misconfiguration | Safe default headers, strict CORS allowlist, `Vary: Origin` |
807
+ | A03 Supply Chain | Zero deps |
808
+ | A04 Crypto Failures | Standard Web Crypto only; no home-grown crypto |
809
+ | A05 Injection / XSS | Prototype-safe objects, JSX attribute sanitization |
810
+ | A06 Insecure Design | Fail-closed validation, explicit response modes |
811
+ | A07 Auth Failures | Validation errors are surfaced, not swallowed |
812
+ | A08 Data Integrity | Prototype-safe JSON, `Set-Cookie` stripped from cache |
813
+ | A09 Logging | Structured JSON security events with 60s dedupe |
814
+ | A10 Exceptional Conditions | Fail-closed middleware, no internal leakage |
815
+
816
+ ## Scheduled Tasks
817
+
818
+ ```ts
819
+ app.scheduled(async (event, env, ctx) => {
820
+ console.log('Cron executed:', event.cron);
821
+ });
391
822
 
392
- ```js
393
- app.cors = ["abc.com", "*.cde.com"]; // exact + wildcard subdomains
394
- app.cors = ["*"]; // allow all (default)
823
+ export default {
824
+ fetch: (req, env, ctx) => app.fetch(req, env, ctx),
825
+ scheduled: (event, env, ctx) => app.scheduledHandler?.(event, env, ctx),
826
+ };
395
827
  ```
396
828
 
397
- CORS is emitted automatically, including `OPTIONS` preflight.
829
+ ## Configuration
398
830
 
399
- **Cookies**
400
- Language cookie: `lang=xx; Max-Age=31536000; Path=/; SameSite=Lax`. `HttpOnly` is deliberately **not** set so client JS can read it if needed. Add `Secure` in production by prepending `__Host-` or via response header transformation if required.
831
+ The `Edge` constructor takes no arguments. Behaviour is configured through public properties and route options:
401
832
 
402
- **Headers**
403
- Every response from `Edge` goes through `withHeaders()`, which merges `app.headers` and per-route CORS headers safely.
833
+ | Property | Type | Default |
834
+ |---|---|---|
835
+ | `app.defaults.cors` | `{ origin, methods }` | `origin: '*'`, `methods: 'GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD'` |
836
+ | `app.security.logSecurityEvents` | `boolean` | `true` |
837
+ | `app.security.extraHeaders` | `Record<string, string> \| null` | `null` |
838
+ | `app.security.trustedProxies` | `string[] \| null` | `null` |
404
839
 
405
- **No dependencies**
406
- Nothing is pulled from npm at runtime. Only build-time tooling (esbuild, terser) is dev-only.
840
+ ```ts
841
+ app.defaults.cors = { origin: ['https://app.example.com'], methods: 'GET, POST' };
842
+ app.security.extraHeaders = {
843
+ 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains',
844
+ };
845
+ ```
407
846
 
408
- ---
847
+ ## Performance
848
+
849
+ Zero runtime dependencies. On a local `wrangler dev` benchmark with `autocannon` (10 connections, 10s per route), throughput sits in the same tier as `hono` / `hono/tiny` / a native worker:
850
+
851
+ | Framework | /text req/s | /json req/s |
852
+ |---|---|---|
853
+ | @lengkapp/edge | 503 | 500 |
854
+ | Hono | 506 | 495 |
855
+ | Hono (tiny) | 505 | 498 |
856
+ | Native Workers | 498 | 500 |
409
857
 
410
- ## 12. License
858
+ The security hardening adds only cheap operations to the request path: prototype-safe objects, two regex checks per JSX attribute, and a throttled logger. No new async boundaries, no added dependencies, no hashing on the hot path.
411
859
 
412
- LengkApp Edge License
860
+ The JSX renderer itself is tuned for throughput: single-pass escaping (`charCodeAt` scan with a fast no-op path), direct string concatenation instead of intermediate arrays, cached camelCase → kebab-case conversions for style objects, and a Set-based URL-attribute lookup on the hot path. `ctx.html(<Card />)` skips the redundant string-identity check that a manual `renderToString(<Card />)` call would still hit.
413
861
 
414
- Copyright (c) LengkApp — Yasir Haris
415
- Contact: yh@lengk.app / yasir.haris@gmail.com
862
+ The client is equally lean: it injects a single stylesheet, binds each element exactly once via a `WeakSet`, and uses one delegated click listener for all `_trigger="click"` elements. An in-flight request on an element is aborted when a new one starts, and the loader is deferred by 100ms / held for at least 240ms so fast endpoints never flash UI.
416
863
 
417
- Permission is granted to any person obtaining a copy of this software
418
- and associated documentation files (the "Software") to use, copy, and
419
- distribute the Software free of charge, for any purpose, including
420
- commercial use, subject to the following conditions:
864
+ The device fingerprint is opt-in per element (`_id`) and cached for the document lifetime; it is warmed during boot when any `[_id]` element exists, so the first click is not delayed by the ~5–15 ms of Canvas / Audio work.
421
865
 
422
- 1. The Software may not be modified, adapted, or altered in any way
423
- without prior written permission from the copyright holder.
866
+ ## Security Posture Summary
424
867
 
425
- 2. Redistribution of the Software, in whole or in part, must retain
426
- this LICENSE file unmodified and include the copyright notice above.
868
+ | Layer | Mechanism |
869
+ |---|---|
870
+ | HTML insertion (client) | Response text parsed into a `<template>`; placement driven by `_in` / `_out` / `_before` / `_after` |
871
+ | Script execution (client) | Extracted `<script>` elements re-created and appended to `<head>`; external scripts de-duplicated by absolute URL |
872
+ | Request hygiene (client) | `credentials: 'same-origin'`, `X-Requested-With: XMLHttpRequest`, optional `X-DeviceId` (only when `_id` is present), abortable via `AbortController`, `_timeout` (default 20s) |
873
+ | Cookies | `credentials: 'same-origin'`; names validated on the server |
874
+ | Server response headers | `nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy` |
875
+ | Server middleware | Fail-closed validation; structured security logs; strict CORS allowlist |
876
+ | Prototype pollution | `Object.create(null)` + forbidden-key filtering in params, cookies, JSON, JSX (server) |
877
+ | Build | Reserved exports/properties, `keep_quoted: "strict"`, per-bundle post-minification self-test |
427
878
 
428
- 3. This permission notice does not grant any right to use the
429
- LengkApp name, brand, or trademarks without separate written
430
- permission.
879
+ ## License
431
880
 
432
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
433
- OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
434
- MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
435
- NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHOR OR COPYRIGHT HOLDER BE
436
- LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
437
- OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
438
- WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
881
+ MIT — see [LICENSE](./LICENSE) for the full text.
882
+ © LengkApp — Yasir Haris