@lengkapp/edge 0.0.40 → 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
@@ -2,9 +2,11 @@
2
2
 
3
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
- Inspired by [Hono](https://hono.dev), `@lengkapp/edge` aims for the same class of performance while shipping with **zero runtime dependencies**.
5
+ Inspired by Hono, `@lengkapp/edge` aims for the same class of performance while shipping with zero runtime dependencies.
6
6
 
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 Controls](#security-controls).
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).
8
+
9
+ ---
8
10
 
9
11
  ## Table of Contents
10
12
 
@@ -15,21 +17,23 @@ Inspired by [Hono](https://hono.dev), `@lengkapp/edge` aims for the same class o
15
17
  - [JSX Support](#jsx-support)
16
18
  - [Full Example](#full-example)
17
19
  - [Client (Declarative Partial Updates)](#client-declarative-partial-updates)
18
- - [Device Identity](#device-identity)
20
+ - [Device Fingerprint](#device-fingerprint)
19
21
  - [CSRF Protection](#csrf-protection)
20
22
  - [Security Controls](#security-controls)
23
+ - [OWASP 2025 Coverage](#owasp-2025-coverage)
21
24
  - [Scheduled Tasks](#scheduled-tasks)
22
25
  - [Configuration](#configuration)
23
26
  - [Performance](#performance)
24
- - [Build](#build)
25
27
  - [Security Posture Summary](#security-posture-summary)
26
28
  - [License](#license)
27
29
 
30
+ ---
31
+
28
32
  ## Features
29
33
 
30
34
  ### Server
31
35
 
32
- - **Trie-based routing** – static and dynamic routes (`/users/:id`)
36
+ - **Trie-based routing** – static & dynamic routes (`/users/:id`)
33
37
  - **JSX support** – pass JSX straight to `ctx.html(<Card />)`; no build step, no manual `renderToString` call
34
38
  - **Middleware** – CORS, logging, caching, compression, validation
35
39
  - **Cookie helpers** with validation
@@ -39,12 +43,13 @@ Inspired by [Hono](https://hono.dev), `@lengkapp/edge` aims for the same class o
39
43
  ### Client
40
44
 
41
45
  - **Declarative partial updates** via `_get` / `_post` and the placement modes `_in`, `_out`, `_before`, `_after`
42
- - **Client-side navigation** via `_go` (same tab) and `_open` (new tab)
43
- - **Opt-in device identity** via `_id` — attaches an `X-DeviceId` header to `_get` / `_post` requests, sourced from an optional `window.deviceId()` hook
44
- - **Click, load, and visibility triggers** – `click` (default), `load`, `visible`
45
- - **JSON and form bodies** – `_json="#a,#b"` or `_form="#signup"`
46
- - **Built-in loading and error states** – spinner loader, abortable requests, one-click retry
47
- - **Error toast host** via `_toast` — on a non-2xx response, inject the response body into a chosen element
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
48
53
  - **Zero dependencies**
49
54
 
50
55
  ### Security
@@ -55,6 +60,8 @@ Inspired by [Hono](https://hono.dev), `@lengkapp/edge` aims for the same class o
55
60
  - **Fail-closed middleware** – validation and handler errors deny by default
56
61
  - **Strict CORS allowlist** – per-origin reflection with `Vary: Origin`
57
62
 
63
+ ---
64
+
58
65
  ## Installation
59
66
 
60
67
  ```bash
@@ -63,7 +70,7 @@ npm install @lengkapp/edge
63
70
 
64
71
  ## Quick Start
65
72
 
66
- **`worker.js`**
73
+ `worker.js`:
67
74
 
68
75
  ```ts
69
76
  import { Edge } from '@lengkapp/edge';
@@ -76,14 +83,16 @@ app.get('/users/:id', (ctx) => ctx.json({ id: ctx.params.id }));
76
83
  export default app;
77
84
  ```
78
85
 
79
- Or, if you use JSX, **`worker.tsx`**:
86
+ Or, if you will use JSX, `worker.tsx`:
80
87
 
81
88
  ```tsx
82
89
  import { Edge, jsx, Fragment } from '@lengkapp/edge';
83
90
 
84
91
  const app = new Edge();
85
92
 
86
- const Card = () => <div>card</div>;
93
+ const Card = () => (
94
+ <div>card</div>
95
+ );
87
96
 
88
97
  const LandingPage = () => (
89
98
  <>
@@ -97,7 +106,7 @@ app.get('/', () => <LandingPage />);
97
106
  export default app;
98
107
  ```
99
108
 
100
- **`tsconfig.json`** (TypeScript):
109
+ If you use TypeScript, `tsconfig.json`:
101
110
 
102
111
  ```json
103
112
  {
@@ -117,18 +126,18 @@ export default app;
117
126
  }
118
127
  ```
119
128
 
120
- **`wrangler.jsonc`**
129
+ `wrangler.jsonc`:
121
130
 
122
131
  ```jsonc
123
132
  {
124
133
  "$schema": "./node_modules/wrangler/config-schema.json",
125
134
  "name": "my-edge-app",
126
135
  "main": "worker.js",
127
- "compatibility_date": "2026-09-14"
136
+ "compatibility_date": "2026-09-06"
128
137
  }
129
138
  ```
130
139
 
131
- Or **`wrangler.toml`**:
140
+ Or `wrangler.toml`:
132
141
 
133
142
  ```toml
134
143
  name = "my-edge-app"
@@ -147,49 +156,25 @@ wrangler deploy
147
156
  ### Context
148
157
 
149
158
  | Member | Description |
150
- | --- | --- |
151
- | `ctx.req` | Incoming `Request` |
159
+ |---|---|
160
+ | `ctx.req` | Incoming Request |
152
161
  | `ctx.env` | Environment bindings |
153
- | `ctx.executionCtx` | `ExecutionContext` |
162
+ | `ctx.executionCtx` | ExecutionContext |
154
163
  | `ctx.params` | Prototype-safe route params object |
155
- | `ctx.status` | Default response status (`200`) |
156
- | `ctx.headers` | Response `Headers` |
157
- | `ctx.query` | `URLSearchParams` |
158
- | `ctx.url` | Parsed `URL` object |
164
+ | `ctx.status` | Default response status (200) |
165
+ | `ctx.headers` | Response Headers |
166
+ | `ctx.query` | URLSearchParams |
167
+ | `ctx.url` | Parsed URL object |
159
168
  | `ctx.getCookie(name)` | Read a cookie |
160
169
  | `ctx.setCookie(name, value, options)` | Set a cookie (name validated) |
161
170
  | `ctx.deleteCookie(name, options)` | Delete a cookie |
162
171
  | `ctx.text(data, status?, headers?)` | Plain-text response |
163
172
  | `ctx.json(data, status?, headers?)` | JSON response |
164
- | `ctx.html(data, status?, headers?)` | HTML response — accepts a raw string, a JSX element, or an array of JSX elements. Async (awaits JSX rendering). |
165
- | `ctx.page(data, status?, headers?)` | Same as `ctx.html`, but prepends `<!DOCTYPE html>` if the body does not already start with one. Async. |
166
- | `ctx.redirect(location, status?)` | Redirect (default `302`), preserving headers already set on the context |
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 |
167
175
 
168
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`.
169
177
 
170
- ### Ambient Context (`useCtx`)
171
-
172
- `useCtx()` returns the `Context` of the request whose render is currently executing. It is backed by a synchronous stack — no `AsyncLocalStorage`, no compatibility flags.
173
-
174
- ```tsx
175
- import { useCtx } from '@lengkapp/edge';
176
-
177
- async function User() {
178
- // MUST be called synchronously, before any await.
179
- const ctx = useCtx();
180
- const res = await fetch(`https://api.example.com/users/${ctx.params.id}`);
181
- const data = await res.json();
182
- // After the await, use the captured local `ctx` — do not call useCtx() again.
183
- return <div>{data.name}</div>;
184
- }
185
- ```
186
-
187
- **Contract:**
188
-
189
- - Call `useCtx()` at the top of a component, before any `await`.
190
- - After an `await`, use the captured local reference.
191
- - Throws if called outside an active render (module top-level, `scheduled` handler, `waitUntil` callback).
192
-
193
178
  ### Route Options
194
179
 
195
180
  ```ts
@@ -200,20 +185,20 @@ app.get('/logged', { log: true }, handler);
200
185
  app.post('/submit', { validate: (ctx) => /* ... */ true }, handler);
201
186
  ```
202
187
 
203
- | Option | Behavior |
204
- | --- | --- |
205
- | `cache` | `{ ttl, staleWhileRevalidate }` — TTL in seconds, default `3600`. Only applied to `GET` responses with status `200`. `Set-Cookie` is stripped from cached responses. |
206
- | `cors` | Default is `origin: '*'`. For production, use a strict allowlist (see below). |
207
- | `compress` | Negotiates `gzip` or `deflate` from `Accept-Encoding` using the native `CompressionStream`. |
208
- | `log` | Logs `METHOD URL - STATUS` to the console. |
209
- | `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. |
188
+ **Cache:** `{ ttl, staleWhileRevalidate }` — TTL in seconds, default 3600; only applied to GET responses with status 200. `Set-Cookie` is stripped from cached responses.
210
189
 
211
- Strict CORS allowlist:
190
+ **CORS:** The default is `origin: '*'`. For production, use a strict allowlist:
212
191
 
213
192
  ```ts
214
193
  app.defaults.cors.origin = ['https://app.example.com'];
215
194
  ```
216
195
 
196
+ **Compress:** Negotiates gzip or deflate from `Accept-Encoding` using the native `CompressionStream`.
197
+
198
+ **Log:** Logs `METHOD URL - STATUS` to the console.
199
+
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.
201
+
217
202
  ## JSX Support
218
203
 
219
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.
@@ -243,10 +228,10 @@ app.get('/', () => <LandingPage />);
243
228
  ```tsx
244
229
  import { renderToString } from '@lengkapp/edge';
245
230
 
246
- const html = await renderToString(<Card title="Hello" />);
231
+ const html = renderToString(<Card title="Hello" />);
247
232
  ```
248
233
 
249
- ### `renderToString` hardening
234
+ **`renderToString` hardening:**
250
235
 
251
236
  - Tag names must match `^[A-Za-z][A-Za-z0-9-]*$`.
252
237
  - Attribute names must match `^[A-Za-z_:][A-Za-z0-9_:.-]*$`.
@@ -254,13 +239,10 @@ const html = await renderToString(<Card title="Hello" />);
254
239
  - `href` / `src` / `action` / `formaction` / `xlink:href` values beginning with `javascript:`, `vbscript:`, or `data:text/html` are stripped.
255
240
  - Prototype keys (`__proto__`, `constructor`, `prototype`) are rejected.
256
241
  - All string values are HTML-escaped.
257
-
258
- ### Rendering details
259
-
260
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`, …).
261
243
  - **Aliases.** `className` → `class`, `htmlFor` → `for`.
262
244
  - **Boolean attributes.** `checked`, `disabled`, `required`, `readonly`, `multiple`, etc. emit as bare attributes when `true` and are dropped when `false`.
263
- - **`dangerouslySetInnerHTML`.** Supported via `dangerouslySetInnerHTML={{ __html: '…' }}` — the value is inserted verbatim and is **not** escaped. Only use it with trusted content.
245
+ - **`dangerouslySetInnerHTML`.** Supported via `dangerouslySetInnerHTML={{ __html: '…' }}` — the value is inserted verbatim and is not escaped. Only use it with trusted content.
264
246
 
265
247
  ## Full Example
266
248
 
@@ -272,7 +254,7 @@ A single file that exercises every server feature.
272
254
  // Demonstrates every feature of @lengkapp/edge:
273
255
  // - static & dynamic routes, all HTTP methods
274
256
  // - params, query, cookies (get/set/delete)
275
- // - ctx.text / ctx.json / ctx.html / ctx.page / ctx.redirect
257
+ // - ctx.text / ctx.json / ctx.html / ctx.redirect
276
258
  // - JSX rendering (elements, Fragments, function components, arrays)
277
259
  // - style objects, boolean attributes, void elements,
278
260
  // className/htmlFor aliases, dangerouslySetInnerHTML
@@ -280,14 +262,12 @@ A single file that exercises every server feature.
280
262
  // - security.extraHeaders, security.logSecurityEvents
281
263
  // - scheduled handler
282
264
  // - returning JSX directly from a handler
283
- // - useCtx() inside an async component
284
265
 
285
266
  import {
286
267
  Edge,
287
268
  Context,
288
269
  Fragment,
289
270
  renderToString,
290
- useCtx,
291
271
  type JSXNode,
292
272
  type RouteOptions,
293
273
  } from '@lengkapp/edge';
@@ -302,7 +282,6 @@ function Layout(props: { title: string; children?: any }) {
302
282
  <head>
303
283
  <meta charset="utf-8" />
304
284
  <meta name="viewport" content="width=device-width, initial-scale=1" />
305
- <meta name="_csrf" content="token-here" />
306
285
  <title>{props.title}</title>
307
286
  </head>
308
287
  <body>
@@ -340,14 +319,6 @@ function TodoList(props: { items: string[] }) {
340
319
  );
341
320
  }
342
321
 
343
- // Async component using useCtx() synchronously before any await.
344
- async function CurrentUser() {
345
- const ctx = useCtx();
346
- const id = ctx.params.id ?? 'me';
347
- // ...fetch with ctx.env or ctx.req...
348
- return <UserCard id={id} name={`User ${id}`} />;
349
- }
350
-
351
322
  /* ------------------------------------------------------------------ *
352
323
  * App *
353
324
  * ------------------------------------------------------------------ */
@@ -358,15 +329,17 @@ const app = new Edge();
358
329
  app.security.logSecurityEvents = true;
359
330
  app.security.extraHeaders = {
360
331
  'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
361
- 'X-Custom-Powered-By': 'lengkapp-server',
332
+ 'X-Custom-Powered-By': 'edge-server',
362
333
  };
363
334
 
364
335
  /* ================================================================== *
365
336
  * Basic routes *
366
337
  * ================================================================== */
367
338
 
339
+ // Plain text
368
340
  app.get('/health', (ctx) => ctx.text('ok'));
369
341
 
342
+ // JSON with a custom status
370
343
  app.get('/api/time', (ctx) =>
371
344
  ctx.json({ now: new Date().toISOString() }, 200)
372
345
  );
@@ -375,7 +348,7 @@ app.get('/api/time', (ctx) =>
375
348
  // a text/html Response.
376
349
  app.get('/', () => (
377
350
  <Layout title="Home">
378
- <h1>Hello from lengkapp-server</h1>
351
+ <h1>Hello from edge-server</h1>
379
352
  <p>This page was rendered from JSX.</p>
380
353
  <TodoList items={['Write routes', 'Render JSX', 'Ship it']} />
381
354
  </Layout>
@@ -386,7 +359,10 @@ app.get('/about', (ctx) =>
386
359
  ctx.html(
387
360
  <Layout title="About">
388
361
  <h1>About</h1>
389
- <p>Fragments, components, arrays — all supported.</p>
362
+ <p>
363
+ Fragments, components, arrays — all supported.
364
+ </p>
365
+ {/* Array of JSX is allowed inside a fragment */}
390
366
  <Fragment>
391
367
  <UserCard id="1" name="Ada" admin />
392
368
  <UserCard id="2" name="Grace" />
@@ -395,11 +371,6 @@ app.get('/about', (ctx) =>
395
371
  )
396
372
  );
397
373
 
398
- // ctx.page prepends <!DOCTYPE html> if missing
399
- app.get('/page', (ctx) =>
400
- ctx.page(<Layout title="Page"><h1>Full document</h1></Layout>)
401
- );
402
-
403
374
  // ctx.html also accepts a raw HTML string (passes through unchanged)
404
375
  app.get('/raw', (ctx) =>
405
376
  ctx.html('<h1>Raw HTML</h1><p>Not escaped.</p>')
@@ -410,7 +381,7 @@ app.get('/raw', (ctx) =>
410
381
  * ================================================================== */
411
382
 
412
383
  // Dynamic route: /users/:id
413
- app.get('/users/:id', async (ctx) => {
384
+ app.get('/users/:id', (ctx) => {
414
385
  const { id } = ctx.params;
415
386
  return ctx.html(
416
387
  <Layout title={`User ${id}`}>
@@ -419,11 +390,6 @@ app.get('/users/:id', async (ctx) => {
419
390
  );
420
391
  });
421
392
 
422
- // Async component + useCtx()
423
- app.get('/me/:id', (ctx) =>
424
- ctx.html(<Layout title="Me"><CurrentUser /></Layout>)
425
- );
426
-
427
393
  // Multiple params: /posts/:year/:slug
428
394
  app.get('/posts/:year/:slug', (ctx) => {
429
395
  const { year, slug } = ctx.params;
@@ -488,42 +454,51 @@ app.delete('/api/items/:id', (ctx) =>
488
454
  );
489
455
 
490
456
  app.options('/api/items', (ctx) => ctx.text('', 204));
457
+
491
458
  app.head('/api/items', (ctx) => ctx.text('', 200));
492
459
 
493
460
  /* ================================================================== *
494
461
  * Route options: cors, cache, compress, log, validate *
495
462
  * ================================================================== */
496
463
 
464
+ // CORS with a wildcard origin
497
465
  app.get(
498
466
  '/cors-open',
499
467
  { cors: true, log: true },
500
468
  (ctx) => ctx.json({ cors: 'wildcard' })
501
469
  );
502
470
 
471
+ // CORS with an allow-list + credentials
503
472
  app.get(
504
473
  '/cors-restricted',
505
474
  {
506
475
  cors: {
507
476
  origin: ['https://app.example.com', 'https://admin.example.com'],
508
477
  methods: 'GET, POST',
509
- headers: 'Content-Type, X-CSRF-Token, X-DeviceId',
478
+ headers: 'Content-Type, X-CSRF-Token',
510
479
  },
511
480
  },
512
481
  (ctx) => ctx.json({ cors: 'restricted' })
513
482
  );
514
483
 
484
+ // Caching: cache the GET response for 60s, revalidate in background
515
485
  app.get(
516
486
  '/cached',
517
- { cache: { ttl: 60, staleWhileRevalidate: 30 }, log: true },
487
+ {
488
+ cache: { ttl: 60, staleWhileRevalidate: 30 },
489
+ log: true,
490
+ },
518
491
  (ctx) => ctx.json({ generatedAt: Date.now() })
519
492
  );
520
493
 
494
+ // Compression (gzip / deflate based on Accept-Encoding)
521
495
  app.get(
522
496
  '/big',
523
497
  { compress: true },
524
498
  (ctx) => ctx.html(`<pre>${'x'.repeat(5000)}</pre>`)
525
499
  );
526
500
 
501
+ // Request validation — return false to get a 400 automatically
527
502
  app.post(
528
503
  '/admin',
529
504
  {
@@ -535,6 +510,7 @@ app.post(
535
510
  (ctx) => ctx.json({ ok: true })
536
511
  );
537
512
 
513
+ // Everything combined
538
514
  const everythingOptions: RouteOptions = {
539
515
  cors: { origin: '*' },
540
516
  cache: { ttl: 120, staleWhileRevalidate: 60 },
@@ -558,6 +534,7 @@ app.get('/everything', everythingOptions, (ctx) =>
558
534
  app.get('/jsx/gallery', (ctx) =>
559
535
  ctx.html(
560
536
  <Layout title="JSX Gallery">
537
+ {/* Style objects → kebab-cased, numbers get px added */}
561
538
  <div
562
539
  style={{
563
540
  backgroundColor: 'tomato',
@@ -569,31 +546,44 @@ app.get('/jsx/gallery', (ctx) =>
569
546
  Styled box
570
547
  </div>
571
548
 
572
- <label className="lbl" htmlFor="name">Name</label>
549
+ {/* className and htmlFor are aliased to class / for */}
550
+ <label className="lbl" htmlFor="name">
551
+ Name
552
+ </label>
573
553
  <input id="name" type="text" required disabled={false} />
574
554
 
555
+ {/* Boolean attributes: true emits the bare attribute */}
575
556
  <input type="checkbox" checked readOnly />
576
557
  <button disabled>Nope</button>
577
558
 
559
+ {/* Void elements self-close */}
578
560
  <img src="/logo.png" alt="logo" />
579
561
  <br />
580
562
  <hr />
581
563
 
564
+ {/* dangerouslySetInnerHTML */}
582
565
  <div dangerouslySetInnerHTML={{ __html: '<b>trusted</b>' }} />
583
566
 
567
+ {/* Fragments */}
584
568
  <>
585
569
  <p>Fragment child A</p>
586
570
  <p>Fragment child B</p>
587
571
  </>
588
572
 
573
+ {/* Arrays of JSX */}
589
574
  {[<span key="a">A</span>, <span key="b">B</span>, <span key="c">C</span>]}
590
575
 
576
+ {/* Escaping: user-supplied strings are escaped */}
591
577
  <p>{'<script>alert(1)</script>'}</p>
592
578
 
579
+ {/* Dangerous URLs are dropped */}
593
580
  <a href="javascript:alert(1)">nope</a>
594
581
  <a href="https://example.com">ok</a>
595
582
 
583
+ {/* Numbers are stringified and escaped */}
596
584
  <p>Count: {42}</p>
585
+
586
+ {/* null / undefined / booleans render nothing */}
597
587
  <p>{null}{undefined}{false}{true}</p>
598
588
  </Layout>
599
589
  )
@@ -603,8 +593,8 @@ app.get('/jsx/gallery', (ctx) =>
603
593
  * renderToString() standalone *
604
594
  * ================================================================== */
605
595
 
606
- app.get('/jsx/string', async (ctx) => {
607
- const html = await renderToString(
596
+ app.get('/jsx/string', (ctx) => {
597
+ const html = renderToString(
608
598
  <section>
609
599
  <h1>Rendered manually</h1>
610
600
  <p>Via renderToString()</p>
@@ -619,6 +609,7 @@ app.get('/jsx/string', async (ctx) => {
619
609
 
620
610
  app.scheduled(async (event, env, ctx) => {
621
611
  console.log('cron fired at', new Date(event.scheduledTime).toISOString());
612
+ // e.g. warm a cache, prune KV entries, etc.
622
613
  });
623
614
 
624
615
  /* ================================================================== *
@@ -636,11 +627,10 @@ export default {
636
627
  ### Feature → route cheat-sheet
637
628
 
638
629
  | Feature | Route / location |
639
- | --- | --- |
630
+ |---|---|
640
631
  | `ctx.text` | `GET /health` |
641
632
  | `ctx.json` | `GET /api/time`, `POST /api/echo`, … |
642
633
  | `ctx.html` with JSX | `GET /` |
643
- | `ctx.page` | `GET /page` |
644
634
  | `ctx.html` with string | `GET /raw` |
645
635
  | Returning JSX directly | `GET /` |
646
636
  | `ctx.redirect` | `GET /login`, `GET /logout`, `GET /dashboard` |
@@ -655,9 +645,8 @@ export default {
655
645
  | `validate` | `POST /admin`, `/everything` |
656
646
  | `security.extraHeaders` | set once near the top |
657
647
  | `security.logSecurityEvents` | set once near the top |
658
- | `useCtx()` | `CurrentUser` component, `GET /me/:id` |
659
648
  | Fragments | `GET /about`, `GET /jsx/gallery` |
660
- | Function components | `Layout`, `UserCard`, `TodoList`, `CurrentUser` |
649
+ | Function components | `Layout`, `UserCard`, `TodoList` |
661
650
  | Style objects | `GET /jsx/gallery` |
662
651
  | Boolean attrs / void elements | `GET /jsx/gallery` |
663
652
  | `dangerouslySetInnerHTML` | `GET /jsx/gallery` |
@@ -673,23 +662,25 @@ export default {
673
662
  ### Attributes
674
663
 
675
664
  | Attribute | Description |
676
- | --- | --- |
677
- | `_get` / `_post` | Request URL and HTTP method. Only `GET` and `POST` are supported. |
678
- | `_go` | Navigate the current tab to the URL (`location.href = url`). |
679
- | `_open` | Open the URL in a new tab (`window.open(url, '_blank', 'noopener')`). |
680
- | `_id` | Opt in to sending the `X-DeviceId` header on this request. See [Device Identity](#device-identity). Only meaningful on `_get` / `_post`. |
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). |
681
670
  | `_in` | Replace the target's children with the response. |
682
- | `_out` | Replace the target element itself with the response. |
671
+ | `_out` | Replace the target element itself. |
683
672
  | `_before` | Insert the response before the target. |
684
673
  | `_after` | Insert the response after the target. |
685
- | `_trigger` | `click` (default), `load`, or `visible`. |
686
- | `_form` | CSS selector for a `<form>`; its `FormData` becomes the POST body. |
687
- | `_json` | Comma-separated CSS selectors; their values are collected into a JSON POST body. |
688
- | `_toast` | CSS selector — on a non-2xx response, inject the response body there instead of showing the inline error UI. |
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). |
689
680
 
690
- **Targets.** Exactly one placement attribute (`_in`, `_out`, `_before`, `_after`) should be present, and its value is a CSS selector. If multiple are present, the client resolves them in the order `_in`, `_out`, `_before`, `_after`.
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.
691
682
 
692
- **`_json` field scope.** Each entry is a CSS selector resolved with `document.querySelector`. The JSON key is the element's `name`, then its `id`, then the selector string. Checkbox → boolean, checked radio → value, `<select multiple>` → array of values, otherwise the element's value.
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).
693
684
 
694
685
  ### Examples
695
686
 
@@ -698,118 +689,99 @@ export default {
698
689
  <button _get="/more-posts" _in="#posts">Load More</button>
699
690
 
700
691
  <!-- replace this element with the response -->
701
- <div _get="/user-profile" _out="#profile"></div>
692
+ <div _get="/user-profile" _out="this"></div>
702
693
 
703
- <!-- POST JSON built from #username and #password, replace #status -->
704
- <button _post="/login" _json="#username,#password" _in="#status">Login</button>
694
+ <!-- POST JSON built from form fields, replace the children of #status -->
695
+ <button _post="/login" _json="username,password" _in="#status">Login</button>
705
696
 
706
697
  <!-- POST a whole form -->
707
698
  <form id="signup">…</form>
708
699
  <button _post="/signup" _form="#signup" _in="#result">Sign up</button>
709
700
 
710
701
  <!-- fetch lazily when the element scrolls into view -->
711
- <div _get="/lazy" _in="#feed" _trigger="visible"></div>
702
+ <div _get="/lazy" _in="this" _trigger="visible"></div>
712
703
 
713
- <!-- same-tab navigation -->
714
- <a href="/dashboard" _go>Dashboard</a>
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>
709
+
710
+ <!-- same-tab navigation with a device id -->
711
+ <a href="/dashboard" _go _id>Dashboard</a>
715
712
 
716
713
  <!-- new-tab navigation -->
717
714
  <a href="/docs" _open>Docs</a>
718
715
 
719
- <!-- attach the X-DeviceId header to this POST -->
716
+ <!-- POST with X-DeviceId header -->
720
717
  <button _post="/like" _id _in="#card-3">Like</button>
721
-
722
- <!-- on error, drop the response body into a toast host -->
723
- <button _post="/submit" _form="#f" _toast="#toasts">Submit</button>
724
718
  ```
725
719
 
726
720
  ### Triggers
727
721
 
728
- - **`click`** (default) — the handler calls `event.preventDefault()` and issues the request.
729
- - **`load`** — the request fires as soon as the element is initialized.
730
- - **`visible`** — an `IntersectionObserver` fires the request the first time the element enters the viewport, then disconnects.
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.
731
725
 
732
726
  ### How Content Is Inserted
733
727
 
734
- 1. The request is sent with the browser defaults for credentials (same-origin).
735
- 2. The `X-CSRF-Token` header is populated from `<meta name="_csrf">` (see [CSRF Protection](#csrf-protection)).
736
- 3. When the triggering element carries `_id`, the `X-DeviceId` header is added (see [Device Identity](#device-identity)).
737
- 4. For `_post`, the body is `FormData` from `_form`, JSON from `_json`, or empty. `_post` requires one of them; otherwise the request fails with a client-side error.
738
- 5. The response text is parsed into a `<template>`.
739
- 6. Any `<script>` elements inside the fragment are replaced by inert comment markers that hold their DOM position.
740
- 7. The script-free fragment is inserted according to the placement mode (`_in`, `_out`, `_before`, `_after`).
741
- 8. Each script is then re-created as a fresh `<script>` element at its marker and injected sequentially:
742
- - Inline scripts execute synchronously.
743
- - `async` external scripts are fire-and-forget.
744
- - Non-async external scripts (classic and `type="module"`) are awaited via their `load` / `error` events, with a 30-second safety timeout so a stuck URL cannot wedge the queue.
745
- 9. Newly inserted `[_get]` / `[_post]` / `[_go]` / `[_open]` elements are picked up by a `MutationObserver` on `document.documentElement`.
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.
746
738
 
747
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.
748
740
 
749
741
  ### Loader & Error States
750
742
 
751
- - A spinner (Lucide `loader-circle` SVG) is inserted as soon as the request starts:
752
- - `_in` — the target's existing children are cleared and the spinner is placed inside.
753
- - `_out` / `_before` — the spinner is inserted immediately before the target.
754
- - `_after` — the spinner is inserted immediately after the target.
755
- - The spinner is removed when the response (or error) lands.
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.
756
746
  - A new request on the same element aborts the previous one via `AbortController`.
757
- - On failure — non-2xx, network error, or client-side validation error (e.g. `_post` without `_form` / `_json`) — the spinner is replaced in place by an error box containing a message and a **Retry** button that re-issues the request.
758
- - On a non-2xx response, if the triggering element has a `_toast` attribute whose selector resolves, the response body is injected there (using `_in` semantics, script-aware) and no inline error box is shown.
759
- - The stylesheet is injected once and defines `._edge-loader`, `._edge-error`, and a `_edge-spin` keyframe.
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.
760
749
 
761
- ## Device Identity
750
+ ### View Transitions
762
751
 
763
- Add `_id` to any `_get` / `_post` element to attach an `X-DeviceId` header to that request.
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`.
764
753
 
765
- The value is resolved by `deviceId()`, which:
754
+ ## Device Fingerprint
766
755
 
767
- 1. Calls `window.deviceId()` if it is defined and returns a non-null value, coercing the result to a string.
768
- 2. Otherwise returns `new Date().toString()`.
756
+ Add `_id` to any `_get` / `_post` / `_go` / `_open` element to attach a stable device identifier.
769
757
 
770
- `window.deviceId` is designed as a hook so the host application can plug in its own fingerprint implementation (e.g. a client-side identifier, a cookie, or a third-party library). By default, the timestamp fallback is intentionally weak.
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.
771
759
 
772
- ```ts
773
- // Optional: install your own stable identifier.
774
- declare global {
775
- interface Window {
776
- deviceId?: () => string;
777
- }
778
- }
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.
779
761
 
780
- window.deviceId = () => {
781
- let id = localStorage.getItem('device-id');
782
- if (!id) {
783
- id = crypto.randomUUID();
784
- localStorage.setItem('device-id', id);
785
- }
786
- return id;
787
- };
788
- ```
762
+ **Delivery.**
789
763
 
790
- **Delivery.** The header is added only to `_get` / `_post` requests. It is not added to `_go` / `_open` navigation. Without `_id`, no device identifier is computed and no `X-DeviceId` header is sent.
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`).
791
766
 
792
- ## CSRF Protection
767
+ **Fallback.** If `crypto.subtle` is unavailable (non-secure context), a djb2 hash of the same signals is used, with lower collision resistance.
793
768
 
794
- The client automatically attaches the value of `<meta name="_csrf">` (if present) to every `_get` / `_post` request as the `X-CSRF-Token` header.
769
+ Without `_id`, no fingerprint is computed, no header is set, and no URL parameter is added.
795
770
 
796
- ```html
797
- <meta name="_csrf" content="…">
798
- ```
771
+ ## CSRF Protection
799
772
 
800
- On the server, validate that header inside a `validate` option:
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:
801
774
 
802
775
  ```ts
803
776
  app.post('/submit', {
804
- validate: (ctx) => {
805
- const header = ctx.req.headers.get('X-CSRF-Token') || '';
806
- const cookie = ctx.getCookie('csrf_token') || '';
807
- return header.length > 0 && header === cookie;
777
+ validate: async (ctx) => {
778
+ const body = await ctx.req.json().catch(() => ({}));
779
+ return body.csrf_token && body.csrf_token === ctx.getCookie('csrf_token');
808
780
  }
809
781
  }, handler);
810
782
  ```
811
783
 
812
- When the meta tag is absent, the header is sent with an empty value.
784
+ For `_form`-based submissions, read the field from the parsed form data using the same pattern.
813
785
 
814
786
  ## Security Controls
815
787
 
@@ -826,14 +798,14 @@ app.security.extraHeaders = {
826
798
  };
827
799
  ```
828
800
 
829
- ### OWASP 2025 Coverage
801
+ ## OWASP 2025 Coverage
830
802
 
831
803
  | Category | Mitigation |
832
- | --- | --- |
833
- | A01 Broken Access Control | Strict CORS allowlist, explicit placement modes, no implicit trust |
804
+ |---|---|
805
+ | A01 Broken Access Control | Strict CORS allowlist, strict target resolution, no implicit trust |
834
806
  | A02 Security Misconfiguration | Safe default headers, strict CORS allowlist, `Vary: Origin` |
835
- | A03 Supply Chain | Zero runtime dependencies |
836
- | A04 Crypto Failures | No home-grown crypto in the runtime; relies on platform primitives (`Headers`, `CompressionStream`, Web Crypto where the host uses it) |
807
+ | A03 Supply Chain | Zero deps |
808
+ | A04 Crypto Failures | Standard Web Crypto only; no home-grown crypto |
837
809
  | A05 Injection / XSS | Prototype-safe objects, JSX attribute sanitization |
838
810
  | A06 Insecure Design | Fail-closed validation, explicit response modes |
839
811
  | A07 Auth Failures | Validation errors are surfaced, not swallowed |
@@ -859,7 +831,7 @@ export default {
859
831
  The `Edge` constructor takes no arguments. Behaviour is configured through public properties and route options:
860
832
 
861
833
  | Property | Type | Default |
862
- | --- | --- | --- |
834
+ |---|---|---|
863
835
  | `app.defaults.cors` | `{ origin, methods }` | `origin: '*'`, `methods: 'GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD'` |
864
836
  | `app.security.logSecurityEvents` | `boolean` | `true` |
865
837
  | `app.security.extraHeaders` | `Record<string, string> \| null` | `null` |
@@ -874,40 +846,37 @@ app.security.extraHeaders = {
874
846
 
875
847
  ## Performance
876
848
 
877
- 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), and a native worker:
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:
878
850
 
879
- | Framework | `/text` req/s | `/json` req/s |
880
- | --- | --- | --- |
881
- | **@lengkapp/edge** | 503 | 500 |
851
+ | Framework | /text req/s | /json req/s |
852
+ |---|---|---|
853
+ | @lengkapp/edge | 503 | 500 |
882
854
  | Hono | 506 | 495 |
883
855
  | Hono (tiny) | 505 | 498 |
884
856
  | Native Workers | 498 | 500 |
885
857
 
886
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.
887
859
 
888
- The JSX renderer 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.
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.
889
861
 
890
- The client is equally lean: it injects a single stylesheet and binds each element exactly once via an `el.__edgeInit` guard. An in-flight request on an element is aborted when a new one starts.
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.
891
863
 
892
- ## Build
893
-
894
- `build.mjs` bundles `client.js` and `server.js` with esbuild (`bundle: false`, `format: "esm"`, `target: "es2022"`, `minify: true`), then runs Terser with `module: true`, `compress.passes: 2`, and `mangle.toplevel: true` for a second pass. A `/*! … */` banner containing the LICENSE text is prepended to each file, and `client.d.ts`, `server.d.ts`, `README.md`, and `LICENSE` are copied into `dist/`.
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.
895
865
 
896
866
  ## Security Posture Summary
897
867
 
898
868
  | Layer | Mechanism |
899
- | --- | --- |
869
+ |---|---|
900
870
  | HTML insertion (client) | Response text parsed into a `<template>`; placement driven by `_in` / `_out` / `_before` / `_after` |
901
- | Script execution (client) | `<script>` elements lifted out, replaced by comment markers, then re-created at their original position and run in document order |
902
- | Request hygiene (client) | `X-CSRF-Token` from `<meta name="_csrf">`; optional `X-DeviceId` (only when `_id` is present); abortable via `AbortController`; per-element in-flight cancellation |
903
- | Cookies | Browser defaults (same-origin); names validated on the server |
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 |
904
874
  | Server response headers | `nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy` |
905
875
  | Server middleware | Fail-closed validation; structured security logs; strict CORS allowlist |
906
- | Prototype pollution | `Object.create(null)` + forbidden-key filtering in params, cookies, JSX attributes |
907
- | Build | LICENSE banner; esbuild + Terser double-pass minification; per-file `client.d.ts` / `server.d.ts` / `README.md` / `LICENSE` copied to `dist/` |
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 |
908
878
 
909
879
  ## License
910
880
 
911
- MIT — see [LICENSE](./LICENSE) for the full text.
912
-
881
+ MIT — see [LICENSE](./LICENSE) for the full text.
913
882
  © LengkApp — Yasir Haris