@lengkapp/edge 0.0.22 → 0.0.24

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,38 +1,60 @@
1
1
  # @lengkapp/edge
2
2
 
3
- A minimal, high-performance framework for Cloudflare Workers with built-in server-side rendering, routing, auth, rate limiting, caching, and a declarative client-side partial-update library.
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:** Server and client are hardened against the OWASP Top 10:2025 and ASVS 5.0 Layer 1 controls. The client sanitizes every response, works under `require-trusted-types-for 'script'` CSP, is nonce-aware for script execution, and sends CSRF tokens on state-changing requests. See [Security](#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
+ ---
10
+
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
+ - [CSRF Protection](#csrf-protection)
21
+ - [Security Controls](#security-controls)
22
+ - [OWASP 2025 Coverage](#owasp-2025-coverage)
23
+ - [Scheduled Tasks](#scheduled-tasks)
24
+ - [Configuration](#configuration)
25
+ - [Performance](#performance)
26
+ - [Security Posture Summary](#security-posture-summary)
27
+ - [License](#license)
8
28
 
9
29
  ---
10
30
 
11
31
  ## Features
12
32
 
13
33
  ### Server
14
- - **Trie-based routing** – static & dynamic routes (`/users/:id`).
15
- - **JSX support** – pass JSX straight to `ctx.html(<Card />)`; no build step, no manual `renderToString` call.
16
- - **Middleware** – auth, rate limiting, CORS, logging, caching, compression, validation.
17
- - **Cookie helpers** with validation.
18
- - **Scheduled tasks** via Cron triggers.
19
- - **Zero dependencies.**
34
+
35
+ - **Trie-based routing** – static & dynamic routes (`/users/:id`)
36
+ - **JSX support** – pass JSX straight to `ctx.html(<Card />)`; no build step, no manual `renderToString` call
37
+ - **Middleware** – CORS, logging, caching, compression, validation
38
+ - **Cookie helpers** with validation
39
+ - **Scheduled tasks** via Cron triggers
40
+ - **Zero dependencies**
20
41
 
21
42
  ### Client
22
- - **Declarative partial updates** via `_get`, `_post`, `_target`, `_trigger`.
23
- - **Trusted Types aware** – routes all `innerHTML` through an `edge-policy` sanitizer.
24
- - **Nonce-aware script execution** – response scripts must match the page's CSP nonce when one exists.
25
- - **Built-in sanitizer** – strips `<base>`, `<meta refresh>`, `<iframe srcdoc>`, `<object>`, `<embed>`, and `javascript:`/`vbscript:`/`data:text/html` URLs.
26
- - **CSRF-aware** – sends `X-CSRF-Token` on `POST`/`PUT`/`PATCH`/`DELETE`.
27
- - **Content-Type guarded** – only `text/html` and `text/plain` responses are inserted.
28
-
29
- ### Security (both)
30
- - **Prototype-pollution safe** – route params, cookies, JSON bodies, JSX attributes.
31
- - **XSS-hardened JSX** – no `on*` attributes, no `javascript:` URLs, no malformed tag names.
32
- - **Structured security logging** – throttled JSON events for auth/rate-limit/handler failures.
33
- - **Fail-closed middleware** – validation, auth, and rate limit errors deny by default.
34
- - **Opt-in token hashing** – SHA-256 before KV lookup.
35
- - **Strict CORS allowlist** – per-origin reflection with `Vary: Origin`.
43
+
44
+ - **Declarative partial updates** via `_get` / `_post` and the placement modes `_in`, `_out`, `_before`, `_after`
45
+ - **Event, load, and visibility triggers** – `click` (default), `load`, `visible`, or any DOM event name
46
+ - **JSON and form bodies** – `_json="a,b,c"` or `_form="#signup"`
47
+ - **Built-in loading and error states** – deferred spinner, skeleton loader, abortable requests, one-click retry
48
+ - **View Transitions aware** – swaps run inside `document.startViewTransition` when available
49
+ - **Zero dependencies**
50
+
51
+ ### Security
52
+
53
+ - **Prototype-pollution safe** – route params, cookies, JSON bodies, JSX attributes
54
+ - **XSS-hardened JSX** – no `on*` attributes, no `javascript:` URLs, no malformed tag names
55
+ - **Structured security logging** – throttled JSON events for validation and handler failures
56
+ - **Fail-closed middleware** – validation and handler errors deny by default
57
+ - **Strict CORS allowlist** – per-origin reflection with `Vary: Origin`
36
58
 
37
59
  ---
38
60
 
@@ -57,9 +79,9 @@ app.get('/users/:id', (ctx) => ctx.json({ id: ctx.params.id }));
57
79
  export default app;
58
80
  ```
59
81
 
60
- or if you will use jsx `worker.tsx`:
82
+ Or, if you will use JSX, `worker.tsx`:
61
83
 
62
- ```ts
84
+ ```tsx
63
85
  import { Edge, jsx, Fragment } from '@lengkapp/edge';
64
86
 
65
87
  const app = new Edge();
@@ -70,20 +92,19 @@ const Card = () => (
70
92
 
71
93
  const LandingPage = () => (
72
94
  <>
73
- <h1>
74
- hello world
75
- </h1>
95
+ <h1>hello world</h1>
76
96
  <Card />
77
97
  </>
78
98
  );
99
+
79
100
  app.get('/', () => <LandingPage />);
80
101
 
81
102
  export default app;
82
103
  ```
83
104
 
84
- if you use typescript `tsconfig.json`:
105
+ If you use TypeScript, `tsconfig.json`:
85
106
 
86
- ```typescript
107
+ ```json
87
108
  {
88
109
  "compilerOptions": {
89
110
  "jsx": "react",
@@ -112,7 +133,7 @@ if you use typescript `tsconfig.json`:
112
133
  }
113
134
  ```
114
135
 
115
- or `wrangler.toml`:
136
+ Or `wrangler.toml`:
116
137
 
117
138
  ```toml
118
139
  name = "my-edge-app"
@@ -126,14 +147,12 @@ Deploy:
126
147
  wrangler deploy
127
148
  ```
128
149
 
129
- ---
130
-
131
150
  ## Server API
132
151
 
133
152
  ### Context
134
153
 
135
154
  | Member | Description |
136
- |---|---|
155
+ | --- | --- |
137
156
  | `ctx.req` | Incoming Request |
138
157
  | `ctx.env` | Environment bindings |
139
158
  | `ctx.executionCtx` | ExecutionContext |
@@ -141,28 +160,28 @@ wrangler deploy
141
160
  | `ctx.status` | Default response status (200) |
142
161
  | `ctx.headers` | Response Headers |
143
162
  | `ctx.query` | URLSearchParams |
163
+ | `ctx.url` | Parsed URL object |
144
164
  | `ctx.getCookie(name)` | Read a cookie |
145
165
  | `ctx.setCookie(name, value, options)` | Set a cookie (name validated) |
146
166
  | `ctx.deleteCookie(name, options)` | Delete a cookie |
147
167
  | `ctx.text(data, status?, headers?)` | Plain-text response |
148
168
  | `ctx.json(data, status?, headers?)` | JSON response |
149
169
  | `ctx.html(data, status?, headers?)` | HTML response — accepts a raw string, a JSX element, or an array of JSX elements |
170
+ | `ctx.redirect(location, status?)` | Redirect (default 302), preserving headers already set on the context |
150
171
 
151
172
  Every response carries `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, and a restrictive `Permissions-Policy`.
152
173
 
153
174
  ### Route Options
154
175
 
155
176
  ```ts
156
- app.get('/protected', { auth: true }, handler);
157
- app.get('/admin', { auth: { role: 'admin' } }, handler);
158
- app.get('/scoped', { auth: { scopes: ['read', 'write'] } }, handler);
159
- app.get('/limited', { rateLimit: { max: 100, window: 60 } }, handler);
160
- app.get('/cached', { cache: { ttl: 60 } }, handler);
161
- app.get('/api', { cors: true }, handler);
162
- app.post('/submit', { validate: (ctx) => /* ... */ true }, handler);
177
+ app.get('/cached', { cache: { ttl: 60 } }, handler);
178
+ app.get('/api', { cors: true }, handler);
179
+ app.get('/gzip', { compress: true }, handler);
180
+ app.get('/logged', { log: true }, handler);
181
+ app.post('/submit', { validate: (ctx) => /* ... */ true }, handler);
163
182
  ```
164
183
 
165
- **Auth:** Tokens are looked up in `AUTH_KV`. Send via `Authorization: Bearer <token>` or the `auth_token` cookie. Payloads with an `exp` field (Unix seconds) are rejected when expired.
184
+ **Cache:** `{ ttl, staleWhileRevalidate }` — TTL in seconds, default 3600; only applied to GET responses with status 200. `Set-Cookie` is stripped from cached responses.
166
185
 
167
186
  **CORS:** The default is `origin: '*'`. For production, use a strict allowlist:
168
187
 
@@ -170,9 +189,13 @@ app.post('/submit', { validate: (ctx) => /* ... */ true }, handler);
170
189
  app.defaults.cors.origin = ['https://app.example.com'];
171
190
  ```
172
191
 
173
- **Rate limit:** `429` responses carry `Retry-After: 60`.
192
+ **Compress:** Negotiates gzip or deflate from `Accept-Encoding` using the native `CompressionStream`.
193
+
194
+ **Log:** Logs `METHOD URL - STATUS` to the console.
174
195
 
175
- ### JSX Support
196
+ **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.
197
+
198
+ ## JSX Support
176
199
 
177
200
  `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.
178
201
 
@@ -190,6 +213,12 @@ app.get('/list', (ctx) => ctx.html([<Card title="A" />, <Card title="B" />]))
190
213
  app.get('/raw', (ctx) => ctx.html('<p>pre-rendered</p>'));
191
214
  ```
192
215
 
216
+ Returning a JSX element directly from a handler is also supported — it is treated as an HTML response:
217
+
218
+ ```tsx
219
+ app.get('/', () => <LandingPage />);
220
+ ```
221
+
193
222
  `renderToString` is still exported for advanced use cases (for example, embedding rendered HTML inside another response body or email template):
194
223
 
195
224
  ```tsx
@@ -199,14 +228,426 @@ const html = renderToString(<Card title="Hello" />);
199
228
  ```
200
229
 
201
230
  **`renderToString` hardening:**
231
+
202
232
  - Tag names must match `^[A-Za-z][A-Za-z0-9-]*$`.
203
233
  - Attribute names must match `^[A-Za-z_:][A-Za-z0-9_:.-]*$`.
204
234
  - `on*` attributes never serialize.
205
- - `href`/`src`/`action`/`formaction`/`xlink:href` values beginning with `javascript:`, `vbscript:`, or `data:text/html` are stripped.
235
+ - `href` / `src` / `action` / `formaction` / `xlink:href` values beginning with `javascript:`, `vbscript:`, or `data:text/html` are stripped.
206
236
  - Prototype keys (`__proto__`, `constructor`, `prototype`) are rejected.
207
237
  - All string values are HTML-escaped.
238
+ - **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`, …).
239
+ - **Aliases.** `className` → `class`, `htmlFor` → `for`.
240
+ - **Boolean attributes.** `checked`, `disabled`, `required`, `readonly`, `multiple`, etc. emit as bare attributes when `true` and are dropped when `false`.
241
+ - **`dangerouslySetInnerHTML`.** Supported via `dangerouslySetInnerHTML={{ __html: '…' }}` — the value is inserted verbatim and is not escaped. Only use it with trusted content.
208
242
 
209
- ---
243
+ ## Full Example
244
+
245
+ A single file that exercises every server feature.
246
+
247
+ ```tsx
248
+ // sample.tsx
249
+ //
250
+ // Demonstrates every feature of @lengkapp/edge:
251
+ // - static & dynamic routes, all HTTP methods
252
+ // - params, query, cookies (get/set/delete)
253
+ // - ctx.text / ctx.json / ctx.html / ctx.redirect
254
+ // - JSX rendering (elements, Fragments, function components, arrays)
255
+ // - style objects, boolean attributes, void elements,
256
+ // className/htmlFor aliases, dangerouslySetInnerHTML
257
+ // - route options: cors, cache, compress, log, validate
258
+ // - security.extraHeaders, security.logSecurityEvents
259
+ // - scheduled handler
260
+ // - returning JSX directly from a handler
261
+
262
+ import {
263
+ Edge,
264
+ Context,
265
+ Fragment,
266
+ renderToString,
267
+ type JSXNode,
268
+ type RouteOptions,
269
+ } from '@lengkapp/edge';
270
+
271
+ /* ------------------------------------------------------------------ *
272
+ * Small helper components (JSX function components) *
273
+ * ------------------------------------------------------------------ */
274
+
275
+ function Layout(props: { title: string; children?: any }) {
276
+ return (
277
+ <html lang="en">
278
+ <head>
279
+ <meta charset="utf-8" />
280
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
281
+ <title>{props.title}</title>
282
+ </head>
283
+ <body>
284
+ <header>
285
+ <nav>
286
+ <a href="/">Home</a>{' · '}
287
+ <a href="/about">About</a>{' · '}
288
+ <a href="/users/42">User 42</a>{' · '}
289
+ <a href="/dashboard">Dashboard</a>
290
+ </nav>
291
+ </header>
292
+ <main>{props.children}</main>
293
+ <footer>© {new Date().getFullYear()}</footer>
294
+ </body>
295
+ </html>
296
+ );
297
+ }
298
+
299
+ function UserCard(props: { id: string; name: string; admin?: boolean }) {
300
+ return (
301
+ <div class="card" data-id={props.id}>
302
+ <h2>{props.name}</h2>
303
+ {props.admin && <span class="badge">admin</span>}
304
+ </div>
305
+ );
306
+ }
307
+
308
+ function TodoList(props: { items: string[] }) {
309
+ return (
310
+ <ul>
311
+ {props.items.map((item, i) => (
312
+ <li key={i}>{item}</li>
313
+ ))}
314
+ </ul>
315
+ );
316
+ }
317
+
318
+ /* ------------------------------------------------------------------ *
319
+ * App *
320
+ * ------------------------------------------------------------------ */
321
+
322
+ const app = new Edge();
323
+
324
+ // ---- Security: global extra headers + keep security logging on ------
325
+ app.security.logSecurityEvents = true;
326
+ app.security.extraHeaders = {
327
+ 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
328
+ 'X-Custom-Powered-By': 'edge-server',
329
+ };
330
+
331
+ /* ================================================================== *
332
+ * Basic routes *
333
+ * ================================================================== */
334
+
335
+ // Plain text
336
+ app.get('/health', (ctx) => ctx.text('ok'));
337
+
338
+ // JSON with a custom status
339
+ app.get('/api/time', (ctx) =>
340
+ ctx.json({ now: new Date().toISOString() }, 200)
341
+ );
342
+
343
+ // Returning JSX directly from a handler → automatically becomes
344
+ // a text/html Response.
345
+ app.get('/', () => (
346
+ <Layout title="Home">
347
+ <h1>Hello from edge-server</h1>
348
+ <p>This page was rendered from JSX.</p>
349
+ <TodoList items={['Write routes', 'Render JSX', 'Ship it']} />
350
+ </Layout>
351
+ ));
352
+
353
+ // Explicit ctx.html with a JSX tree
354
+ app.get('/about', (ctx) =>
355
+ ctx.html(
356
+ <Layout title="About">
357
+ <h1>About</h1>
358
+ <p>
359
+ Fragments, components, arrays — all supported.
360
+ </p>
361
+ {/* Array of JSX is allowed inside a fragment */}
362
+ <Fragment>
363
+ <UserCard id="1" name="Ada" admin />
364
+ <UserCard id="2" name="Grace" />
365
+ </Fragment>
366
+ </Layout>
367
+ )
368
+ );
369
+
370
+ // ctx.html also accepts a raw HTML string (passes through unchanged)
371
+ app.get('/raw', (ctx) =>
372
+ ctx.html('<h1>Raw HTML</h1><p>Not escaped.</p>')
373
+ );
374
+
375
+ /* ================================================================== *
376
+ * Params, query, cookies *
377
+ * ================================================================== */
378
+
379
+ // Dynamic route: /users/:id
380
+ app.get('/users/:id', (ctx) => {
381
+ const { id } = ctx.params;
382
+ return ctx.html(
383
+ <Layout title={`User ${id}`}>
384
+ <UserCard id={id} name={`User #${id}`} />
385
+ </Layout>
386
+ );
387
+ });
388
+
389
+ // Multiple params: /posts/:year/:slug
390
+ app.get('/posts/:year/:slug', (ctx) => {
391
+ const { year, slug } = ctx.params;
392
+ return ctx.json({ year, slug });
393
+ });
394
+
395
+ // Query strings: /search?q=hello&limit=10
396
+ app.get('/search', (ctx) => {
397
+ const q = ctx.query.get('q') ?? '';
398
+ const limit = Number(ctx.query.get('limit') ?? '10');
399
+ return ctx.json({ q, limit });
400
+ });
401
+
402
+ // Cookies: read, write, delete
403
+ app.get('/login', (ctx) => {
404
+ ctx.setCookie('session', 'abc123', {
405
+ path: '/',
406
+ httpOnly: true,
407
+ secure: true,
408
+ sameSite: 'Lax',
409
+ maxAge: 3600,
410
+ });
411
+ return ctx.redirect('/dashboard');
412
+ });
413
+
414
+ app.get('/logout', (ctx) => {
415
+ ctx.deleteCookie('session', { path: '/' });
416
+ return ctx.redirect('/');
417
+ });
418
+
419
+ app.get('/dashboard', (ctx) => {
420
+ const session = ctx.getCookie('session');
421
+ if (!session) return ctx.redirect('/login');
422
+ return ctx.html(
423
+ <Layout title="Dashboard">
424
+ <h1>Dashboard</h1>
425
+ <p>Session: {session}</p>
426
+ </Layout>
427
+ );
428
+ });
429
+
430
+ /* ================================================================== *
431
+ * All HTTP methods *
432
+ * ================================================================== */
433
+
434
+ app.post('/api/echo', async (ctx) => {
435
+ const body = await ctx.req.json().catch(() => null);
436
+ return ctx.json({ received: body }, 201);
437
+ });
438
+
439
+ app.put('/api/items/:id', async (ctx) => {
440
+ const body = await ctx.req.json().catch(() => null);
441
+ return ctx.json({ updated: ctx.params.id, body });
442
+ });
443
+
444
+ app.patch('/api/items/:id', (ctx) =>
445
+ ctx.json({ patched: ctx.params.id })
446
+ );
447
+
448
+ app.delete('/api/items/:id', (ctx) =>
449
+ ctx.json({ deleted: ctx.params.id }, 200)
450
+ );
451
+
452
+ app.options('/api/items', (ctx) => ctx.text('', 204));
453
+
454
+ app.head('/api/items', (ctx) => ctx.text('', 200));
455
+
456
+ /* ================================================================== *
457
+ * Route options: cors, cache, compress, log, validate *
458
+ * ================================================================== */
459
+
460
+ // CORS with a wildcard origin
461
+ app.get(
462
+ '/cors-open',
463
+ { cors: true, log: true },
464
+ (ctx) => ctx.json({ cors: 'wildcard' })
465
+ );
466
+
467
+ // CORS with an allow-list + credentials
468
+ app.get(
469
+ '/cors-restricted',
470
+ {
471
+ cors: {
472
+ origin: ['https://app.example.com', 'https://admin.example.com'],
473
+ methods: 'GET, POST',
474
+ headers: 'Content-Type, X-CSRF-Token',
475
+ },
476
+ },
477
+ (ctx) => ctx.json({ cors: 'restricted' })
478
+ );
479
+
480
+ // Caching: cache the GET response for 60s, revalidate in background
481
+ app.get(
482
+ '/cached',
483
+ {
484
+ cache: { ttl: 60, staleWhileRevalidate: 30 },
485
+ log: true,
486
+ },
487
+ (ctx) => ctx.json({ generatedAt: Date.now() })
488
+ );
489
+
490
+ // Compression (gzip / deflate based on Accept-Encoding)
491
+ app.get(
492
+ '/big',
493
+ { compress: true },
494
+ (ctx) => ctx.html(`<pre>${'x'.repeat(5000)}</pre>`)
495
+ );
496
+
497
+ // Request validation — return false to get a 400 automatically
498
+ app.post(
499
+ '/admin',
500
+ {
501
+ validate: (ctx) => {
502
+ const token = ctx.req.headers.get('X-Admin-Token');
503
+ return token === 'let-me-in';
504
+ },
505
+ },
506
+ (ctx) => ctx.json({ ok: true })
507
+ );
508
+
509
+ // Everything combined
510
+ const everythingOptions: RouteOptions = {
511
+ cors: { origin: '*' },
512
+ cache: { ttl: 120, staleWhileRevalidate: 60 },
513
+ compress: true,
514
+ log: true,
515
+ validate: async (ctx) => ctx.req.method === 'GET',
516
+ };
517
+
518
+ app.get('/everything', everythingOptions, (ctx) =>
519
+ ctx.html(
520
+ <Layout title="Everything">
521
+ <h1>All options at once</h1>
522
+ </Layout>
523
+ )
524
+ );
525
+
526
+ /* ================================================================== *
527
+ * JSX feature gallery *
528
+ * ================================================================== */
529
+
530
+ app.get('/jsx/gallery', (ctx) =>
531
+ ctx.html(
532
+ <Layout title="JSX Gallery">
533
+ {/* Style objects → kebab-cased, numbers get px added */}
534
+ <div
535
+ style={{
536
+ backgroundColor: 'tomato',
537
+ padding: 12,
538
+ opacity: 0.9,
539
+ lineHeight: 1.4, // unitless, stays as-is
540
+ }}
541
+ >
542
+ Styled box
543
+ </div>
544
+
545
+ {/* className and htmlFor are aliased to class / for */}
546
+ <label className="lbl" htmlFor="name">
547
+ Name
548
+ </label>
549
+ <input id="name" type="text" required disabled={false} />
550
+
551
+ {/* Boolean attributes: true emits the bare attribute */}
552
+ <input type="checkbox" checked readOnly />
553
+ <button disabled>Nope</button>
554
+
555
+ {/* Void elements self-close */}
556
+ <img src="/logo.png" alt="logo" />
557
+ <br />
558
+ <hr />
559
+
560
+ {/* dangerouslySetInnerHTML */}
561
+ <div dangerouslySetInnerHTML={{ __html: '<b>trusted</b>' }} />
562
+
563
+ {/* Fragments */}
564
+ <>
565
+ <p>Fragment child A</p>
566
+ <p>Fragment child B</p>
567
+ </>
568
+
569
+ {/* Arrays of JSX */}
570
+ {[<span key="a">A</span>, <span key="b">B</span>, <span key="c">C</span>]}
571
+
572
+ {/* Escaping: user-supplied strings are escaped */}
573
+ <p>{'<script>alert(1)</script>'}</p>
574
+
575
+ {/* Dangerous URLs are dropped */}
576
+ <a href="javascript:alert(1)">nope</a>
577
+ <a href="https://example.com">ok</a>
578
+
579
+ {/* Numbers are stringified and escaped */}
580
+ <p>Count: {42}</p>
581
+
582
+ {/* null / undefined / booleans render nothing */}
583
+ <p>{null}{undefined}{false}{true}</p>
584
+ </Layout>
585
+ )
586
+ );
587
+
588
+ /* ================================================================== *
589
+ * renderToString() standalone *
590
+ * ================================================================== */
591
+
592
+ app.get('/jsx/string', (ctx) => {
593
+ const html = renderToString(
594
+ <section>
595
+ <h1>Rendered manually</h1>
596
+ <p>Via renderToString()</p>
597
+ </section>
598
+ );
599
+ return ctx.html(html);
600
+ });
601
+
602
+ /* ================================================================== *
603
+ * Scheduled handler *
604
+ * ================================================================== */
605
+
606
+ app.scheduled(async (event, env, ctx) => {
607
+ console.log('cron fired at', new Date(event.scheduledTime).toISOString());
608
+ // e.g. warm a cache, prune KV entries, etc.
609
+ });
610
+
611
+ /* ================================================================== *
612
+ * Cloudflare Workers entry points *
613
+ * ================================================================== */
614
+
615
+ export default {
616
+ fetch: (req: Request, env: any, ctx: ExecutionContext) =>
617
+ app.fetch(req, env, ctx),
618
+ scheduled: (event: ScheduledEvent, env: any, ctx: ExecutionContext) =>
619
+ app.scheduledHandler?.(event, env, ctx),
620
+ };
621
+ ```
622
+
623
+ ### Feature → route cheat-sheet
624
+
625
+ | Feature | Route / location |
626
+ | --- | --- |
627
+ | `ctx.text` | `GET /health` |
628
+ | `ctx.json` | `GET /api/time`, `POST /api/echo`, … |
629
+ | `ctx.html` with JSX | `GET /` |
630
+ | `ctx.html` with string | `GET /raw` |
631
+ | Returning JSX directly | `GET /` |
632
+ | `ctx.redirect` | `GET /login`, `GET /logout`, `GET /dashboard` |
633
+ | `ctx.params` | `GET /users/:id`, `GET /posts/:year/:slug` |
634
+ | `ctx.query` | `GET /search` |
635
+ | `ctx.getCookie` / `setCookie` / `deleteCookie` | `/login`, `/logout`, `/dashboard` |
636
+ | All HTTP verbs | `/api/echo` (POST), `/api/items/:id` (PUT/PATCH/DELETE), `/api/items` (OPTIONS/HEAD) |
637
+ | `cors` | `/cors-open`, `/cors-restricted`, `/everything` |
638
+ | `cache` | `/cached`, `/everything` |
639
+ | `compress` | `/big`, `/everything` |
640
+ | `log` | `/cors-open`, `/cached`, `/everything` |
641
+ | `validate` | `POST /admin`, `/everything` |
642
+ | `security.extraHeaders` | set once near the top |
643
+ | `security.logSecurityEvents` | set once near the top |
644
+ | Fragments | `GET /about`, `GET /jsx/gallery` |
645
+ | Function components | `Layout`, `UserCard`, `TodoList` |
646
+ | Style objects | `GET /jsx/gallery` |
647
+ | Boolean attrs / void elements | `GET /jsx/gallery` |
648
+ | `dangerouslySetInnerHTML` | `GET /jsx/gallery` |
649
+ | `renderToString()` standalone | `GET /jsx/string` |
650
+ | `scheduled()` | bottom of file |
210
651
 
211
652
  ## Client (Declarative Partial Updates)
212
653
 
@@ -217,70 +658,92 @@ const html = renderToString(<Card title="Hello" />);
217
658
  ### Attributes
218
659
 
219
660
  | Attribute | Description |
220
- |---|---|
221
- | `_get` / `_post` / `_put` / `_patch` / `_delete` | URL + method |
222
- | `_target` | CSS selector, or `"this"` |
223
- | `_trigger` | Event list: `click`, `load`, `visible`, `intersect`, etc. |
224
- | `_form` | Form ID to serialize as URL-encoded body |
225
- | `_json` | Comma-separated input names to send as JSON |
226
- | `_skeleton` | `"false"` disables the loading skeleton |
227
- | `_retry` | `"false"` disables the retry button |
661
+ | --- | --- |
662
+ | `_get` / `_post` | Request URL and HTTP method. Only GET and POST are supported. |
663
+ | `_in` | Replace the target's children with the response. |
664
+ | `_out` | Replace the target element itself. |
665
+ | `_before` | Insert the response before the target. |
666
+ | `_after` | Insert the response after the target. |
667
+ | `_trigger` | `click` (default), `load`, `visible`, or any DOM event name. |
668
+ | `_form` | CSS selector or element ID of a form to serialize as the body. |
669
+ | `_json` | Comma-separated field names to send as a JSON body. |
670
+ | `_loader` | `spinner` (default), `skeleton`, or `none` / `off` / `false` to disable. |
671
+ | `_timeout` | Request timeout in milliseconds (default 20000). |
672
+
673
+ **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.
674
+
675
+ **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).
228
676
 
229
677
  ### Examples
230
678
 
231
679
  ```html
232
- <button _get="/more-posts" _target="#posts">Load More</button>
680
+ <!-- replace the children of #posts with the response -->
681
+ <button _get="/more-posts" _in="#posts">Load More</button>
233
682
 
234
- <div _get="/user-profile" _target="this"></div>
683
+ <!-- replace this element with the response -->
684
+ <div _get="/user-profile" _out="this"></div>
235
685
 
236
- <button _post="/login" _json="username,password" _target="#status">Login</button>
686
+ <!-- POST JSON built from form fields, replace the children of #status -->
687
+ <button _post="/login" _json="username,password" _in="#status">Login</button>
237
688
 
238
- <div _get="/lazy" _target="this" _trigger="visible"></div>
689
+ <!-- POST a whole form -->
690
+ <form id="signup">…</form>
691
+ <button _post="/signup" _form="#signup" _in="#result">Sign up</button>
692
+
693
+ <!-- fetch lazily when the element scrolls into view -->
694
+ <div _get="/lazy" _in="this" _trigger="visible"></div>
695
+
696
+ <!-- skeleton loader with a 5-second timeout -->
697
+ <div _get="/feed" _in="this" _loader="skeleton" _timeout="5000"></div>
239
698
  ```
240
699
 
700
+ ### Triggers
701
+
702
+ - **`click`** (default) — handled by a single delegated document listener.
703
+ - **`load` / `visible`** — the element is observed with `IntersectionObserver` (300px root margin) and the request fires the first time it enters the viewport.
704
+ - **Any other value** — treated as a DOM event name. The listener is attached the first time the element becomes visible, then fires normally.
705
+
241
706
  ### How Content Is Inserted
242
707
 
243
- 1. Response is fetched with `credentials: 'same-origin'` and `X-Requested-With: XMLHttpRequest`.
244
- 2. `Content-Type` is verified to be `text/html` or `text/plain`.
245
- 3. `res.headers` may contain `x-css-required` / `x-js-required` (with optional `x-css-integrity` / `x-js-integrity` for SRI) — matching resources are injected once.
246
- 4. The HTML is run through `sanitizeHtml`, which removes `<base>`, `<meta refresh>`, `<iframe srcdoc>`, `<object>`, `<embed>`, and neutralizes `javascript:` / `vbscript:` / `data:text/html` URLs.
247
- 5. The sanitized HTML is assigned via `innerHTML` (through a Trusted Types policy named `edge-policy` when available).
248
- 6. Each `<script>` in the result is rebuilt in place so the browser executes it. If the page has a CSP nonce, only scripts carrying the matching nonce run; otherwise all scripts run.
708
+ - The request is sent with `credentials: 'same-origin'` and `X-Requested-With: XMLHttpRequest`.
709
+ - For `_post`, the body is JSON (`_json`), a `FormData` object (`_form`), or empty.
710
+ - The response text is parsed into a `<template>`.
711
+ - 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.
712
+ - Newly inserted `[_get]` / `[_post]` elements are scanned and bound.
713
+ - Placement depends on the target mode:
714
+ - `_in` — the target's existing children are removed, then the fragment is appended.
715
+ - `_out` — the target itself is replaced.
716
+ - `_before` / `_after` — the fragment is inserted adjacent to the target.
249
717
 
250
- ---
718
+ > **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.
719
+
720
+ ### Loader & Error States
721
+
722
+ - The loader is deferred by 100ms: if the response lands before then, no loader is shown at all.
723
+ - Once shown, the loader stays for at least 240ms before the content swaps in, so it never flashes.
724
+ - `_loader="spinner"` (default), `_loader="skeleton"` for a shimmering skeleton, or `_loader="none"` to disable.
725
+ - A new request on the same element aborts the previous one via `AbortController`.
726
+ - 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.
727
+ - Loaders and error states use `role="status"` / `role="alert"` with `aria-busy` set on the target while in flight.
728
+
729
+ ### View Transitions
730
+
731
+ 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`.
251
732
 
252
733
  ## CSRF Protection
253
734
 
254
- The client reads a token from `<meta name="csrf-token" content="...">` or the `XSRF-TOKEN` cookie and sends it as `X-CSRF-Token` on `POST`/`PUT`/`PATCH`/`DELETE`.
735
+ 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:
255
736
 
256
737
  ```ts
257
738
  app.post('/submit', {
258
- validate: (ctx) => {
259
- const sent = ctx.req.headers.get('X-CSRF-Token');
260
- return sent && sent === ctx.getCookie('csrf_token');
739
+ validate: async (ctx) => {
740
+ const body = await ctx.req.json().catch(() => ({}));
741
+ return body.csrf_token && body.csrf_token === ctx.getCookie('csrf_token');
261
742
  }
262
743
  }, handler);
263
744
  ```
264
745
 
265
- ---
266
-
267
- ## Trusted Types / CSP
268
-
269
- If the page enforces `require-trusted-types-for 'script'`, the client creates a policy named `edge-policy` and routes every `innerHTML` through it. The policy runs the built-in sanitizer. To allowlist the policy in CSP:
270
-
271
- ```
272
- Content-Security-Policy:
273
- default-src 'self';
274
- script-src 'self';
275
- style-src 'self';
276
- img-src 'self' data:;
277
- require-trusted-types-for 'script';
278
- trusted-types edge-policy;
279
- frame-ancestors 'none';
280
- base-uri 'self';
281
- ```
282
-
283
- ---
746
+ For `_form`-based submissions, read the field from the parsed form data using the same pattern.
284
747
 
285
748
  ## Security Controls
286
749
 
@@ -289,7 +752,6 @@ const app = new Edge();
289
752
 
290
753
  app.defaults.cors.origin = ['https://app.example.com'];
291
754
 
292
- app.security.hashAuthTokens = true; // SHA-256 token keys, migrate first
293
755
  app.security.logSecurityEvents = true; // structured JSON events (default on)
294
756
  app.security.extraHeaders = {
295
757
  'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
@@ -298,23 +760,21 @@ app.security.extraHeaders = {
298
760
  };
299
761
  ```
300
762
 
301
- ### OWASP 2025 Coverage
763
+ ## OWASP 2025 Coverage
302
764
 
303
765
  | Category | Mitigation |
304
- |---|---|
305
- | A01 Broken Access Control | Auth reasons, expiry enforcement, scope/role checks, strict target resolution |
766
+ | --- | --- |
767
+ | A01 Broken Access Control | Strict CORS allowlist, strict target resolution, no implicit trust |
306
768
  | A02 Security Misconfiguration | Safe default headers, strict CORS allowlist, `Vary: Origin` |
307
- | A03 Supply Chain | Zero deps, SRI honored on injected resources |
308
- | A04 Crypto Failures | Optional SHA-256 token hashing |
309
- | A05 Injection / XSS | Prototype-safe objects, JSX attribute sanitization, Trusted Types policy, response sanitizer |
310
- | A06 Insecure Design | Numeric-sanitized rate limiter, `Retry-After` |
311
- | A07 Auth Failures | Structured failure reasons, no swallowed errors |
769
+ | A03 Supply Chain | Zero deps |
770
+ | A04 Crypto Failures | Standard Web Crypto only; no home-grown crypto |
771
+ | A05 Injection / XSS | Prototype-safe objects, JSX attribute sanitization |
772
+ | A06 Insecure Design | Fail-closed validation, explicit response modes |
773
+ | A07 Auth Failures | Validation errors are surfaced, not swallowed |
312
774
  | A08 Data Integrity | Prototype-safe JSON, `Set-Cookie` stripped from cache |
313
775
  | A09 Logging | Structured JSON security events with 60s dedupe |
314
776
  | A10 Exceptional Conditions | Fail-closed middleware, no internal leakage |
315
777
 
316
- ---
317
-
318
778
  ## Scheduled Tasks
319
779
 
320
780
  ```ts
@@ -328,69 +788,54 @@ export default {
328
788
  };
329
789
  ```
330
790
 
331
- ---
332
-
333
791
  ## Configuration
334
792
 
335
- ### KV Namespaces
336
-
337
- ```bash
338
- wrangler kv namespace create AUTH_KV
339
- wrangler kv namespace create RATE_LIMIT_KV
340
- ```
341
-
342
- ```jsonc
343
- {
344
- "kv_namespaces": [
345
- { "binding": "AUTH_KV", "id": "..." },
346
- { "binding": "RATE_LIMIT_KV", "id": "..." }
347
- ]
348
- }
349
- ```
793
+ The `Edge` constructor takes no arguments. Behaviour is configured through public properties and route options:
350
794
 
351
- Rename the bindings on the instance:
795
+ | Property | Type | Default |
796
+ | --- | --- | --- |
797
+ | `app.defaults.cors` | `{ origin, methods }` | `origin: '*'`, `methods: 'GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD'` |
798
+ | `app.security.logSecurityEvents` | `boolean` | `true` |
799
+ | `app.security.extraHeaders` | `Record<string, string> | null` | `null` |
800
+ | `app.security.trustedProxies` | `string[] | null` | `null` |
352
801
 
353
802
  ```ts
354
- app.authKvBinding = 'CUSTOM_AUTH_KV';
355
- app.rateLimitKvBinding = 'CUSTOM_RATE_KV';
803
+ app.defaults.cors = { origin: ['https://app.example.com'], methods: 'GET, POST' };
804
+ app.security.extraHeaders = {
805
+ 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains',
806
+ };
356
807
  ```
357
808
 
358
- ---
359
-
360
809
  ## Performance
361
810
 
362
811
  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:
363
812
 
364
813
  | Framework | `/text` req/s | `/json` req/s |
365
- |---|---|---|
814
+ | --- | --- | --- |
366
815
  | @lengkapp/edge | 503 | 500 |
367
816
  | Hono | 506 | 495 |
368
817
  | Hono (tiny) | 505 | 498 |
369
818
  | Native Workers | 498 | 500 |
370
819
 
371
- The security hardening adds only cheap operations to the request path: prototype-safe objects, two regex checks per JSX attribute, a sanitizer template parse per response (browser-native parser), and a throttled logger. No new async boundaries, no added dependencies, no hashing on the hot path.
820
+ 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.
372
821
 
373
- 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.
822
+ 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.
374
823
 
375
- ---
824
+ 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.
376
825
 
377
826
  ## Security Posture Summary
378
827
 
379
828
  | Layer | Mechanism |
380
- |---|---|
381
- | HTML insertion | `sanitizeHtml` → `innerHTML` via Trusted Types policy `edge-policy` |
382
- | Dangerous elements | `<base>`, `<meta refresh>`, `<iframe srcdoc>`, `<object>`, `<embed>`, `form[action^="javascript:"]` removed |
383
- | Dangerous URLs | `javascript:`, `vbscript:`, `data:text/html` removed from `href`/`src`/`action`/`formaction`/`xlink:href`/`data` |
384
- | Script execution | Nonce-gated when the page uses CSP nonces; deduplicated by `src`; inline scripts block-wrapped |
385
- | CSRF | `X-CSRF-Token` from meta or cookie on state-changing methods |
829
+ | --- | --- |
830
+ | HTML insertion (client) | Response text parsed into a `<template>`; placement driven by `_in` / `_out` / `_before` / `_after` |
831
+ | Script execution (client) | Extracted `<script>` elements re-created and appended to `<head>`; external scripts de-duplicated by absolute URL |
832
+ | Request hygiene (client) | `credentials: 'same-origin'`, `X-Requested-With: XMLHttpRequest`, abortable via `AbortController`, `_timeout` (default 20s) |
386
833
  | Cookies | `credentials: 'same-origin'`; names validated on the server |
387
834
  | Server response headers | `nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy` |
388
- | Server middleware | Fail-closed; structured security logs; strict CORS allowlist; optional token hashing |
389
- | Prototype pollution | `Object.create(null)` + forbidden-key filtering in params, cookies, JSON, JSX |
835
+ | Server middleware | Fail-closed validation; structured security logs; strict CORS allowlist |
836
+ | Prototype pollution | `Object.create(null)` + forbidden-key filtering in params, cookies, JSON, JSX (server) |
390
837
  | Build | Reserved exports/properties, `keep_quoted: "strict"`, per-bundle post-minification self-test |
391
838
 
392
- ---
393
-
394
839
  ## License
395
840
 
396
841
  See LICENSE. © LengkApp — Yasir Haris