@lengkapp/edge 0.0.42 → 0.0.44

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,882 +1,820 @@
1
1
  # @lengkapp/edge
2
2
 
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.
3
+ A minimal, high-performance framework for Cloudflare Workers with built-in server-side rendering, routing, caching, compression, and a declarative client-side partial-update library.
4
4
 
5
- Inspired by Hono, `@lengkapp/edge` aims for the same class of performance while shipping with zero runtime dependencies.
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](#security-controls).
8
-
9
- ---
5
+ Inspired by [Hono](https://hono.dev), `@lengkapp/edge` targets the same class of performance while shipping with **zero runtime dependencies**.
10
6
 
11
7
  ## Table of Contents
12
8
 
13
9
  - [Features](#features)
14
10
  - [Installation](#installation)
11
+ - [Project Setup](#project-setup)
15
12
  - [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)
13
+ - [Routing](#routing)
14
+ - [Context API](#context-api)
15
+ - [JSX & Server-Side Rendering](#jsx--server-side-rendering)
16
+ - [Route Options](#route-options)
17
+ - [Caching](#caching)
18
+ - [Compression](#compression)
19
+ - [CORS](#cors)
20
+ - [Security](#security)
21
+ - [Scheduled Handler](#scheduled-handler)
22
+ - [Client-Side Partial Updates](#client-side-partial-updates)
23
+ - [Complete Example](#complete-example)
28
24
  - [License](#license)
29
25
 
30
- ---
31
-
32
26
  ## Features
33
27
 
34
- ### Server
28
+ | | Feature | Description |
29
+ |---|---|---|
30
+ | ⚡ | **Fast router** | Static routes in a `Map`, dynamic routes in a trie — O(1) for static, O(segments) for params |
31
+ | 🧩 | **Async JSX SSR** | `renderToString` supports async function components and streams nothing it doesn't need |
32
+ | 🔒 | **Secure by default** | `nosniff`, `DENY` framing, strict referrer policy, permissions policy, URL/attribute sanitisation |
33
+ | 🗄️ | **Edge caching** | `caches.default` integration with `ttl` and `stale-while-revalidate` |
34
+ | 🗜️ | **Compression** | gzip / deflate via `CompressionStream`, content-type aware |
35
+ | 🌐 | **CORS** | Per-route, wildcard / allow-list / exact-match |
36
+ | 🍪 | **Cookies** | Lazy parsing, safe keys, full `Set-Cookie` option support |
37
+ | 🪶 | **Client runtime** | ~2 KB IIFE: fetch + swap HTML from HTML attributes. No build step |
38
+ | 📦 | **Zero deps** | Nothing but the platform |
35
39
 
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**
40
+ ## Installation
42
41
 
43
- ### Client
42
+ ```bash
43
+ npm install @lengkapp/edge
44
+ ```
44
45
 
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**
46
+ The package is ESM-only (`"type": "module"`).
54
47
 
55
- ### Security
48
+ ## Project Setup
56
49
 
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`
50
+ **`wrangler.toml`**
62
51
 
63
- ---
52
+ ```toml
53
+ name = 'my-edge-app'
54
+ main = 'index.js'
55
+ compatibility_date = "2026-08-31"
56
+ ```
64
57
 
65
- ## Installation
58
+ **`package.json`**
66
59
 
67
- ```bash
68
- npm install @lengkapp/edge
60
+ ```json
61
+ {
62
+ "name": "my-edge-app",
63
+ "version": "0.0.1",
64
+ "type": "module",
65
+ "main": "index.js",
66
+ "scripts": {
67
+ "dev": "wrangler dev",
68
+ "deploy": "wrangler deploy --minify"
69
+ },
70
+ "devDependencies": {
71
+ "@lengkapp/edge": "^0.0.40",
72
+ "wrangler": "^4.129.0"
73
+ }
74
+ }
69
75
  ```
70
76
 
71
77
  ## Quick Start
72
78
 
73
- `worker.js`:
79
+ **`index.js`**
74
80
 
75
- ```ts
76
- import { Edge } from '@lengkapp/edge';
81
+ ```jsx
82
+ /** @jsx jsx */
83
+ /** @jsxFrag Fragment */
84
+ import { Edge, jsx, Fragment } from '@lengkapp/edge';
77
85
 
78
86
  const app = new Edge();
79
87
 
80
- app.get('/', (ctx) => ctx.text('Hello World!'));
81
- app.get('/users/:id', (ctx) => ctx.json({ id: ctx.params.id }));
88
+ app.get('/', (c) =>
89
+ c.page(
90
+ <html lang="en">
91
+ <head>
92
+ <meta charset="utf-8" />
93
+ <title>Hello Edge</title>
94
+ </head>
95
+ <body>
96
+ <h1>Hello, {c.query.get('name') || 'world'}!</h1>
97
+ </body>
98
+ </html>
99
+ )
100
+ );
82
101
 
83
102
  export default app;
84
103
  ```
85
104
 
86
- Or, if you will use JSX, `worker.tsx`:
105
+ ```bash
106
+ npm run dev # http://localhost:8787
107
+ npm run deploy
108
+ ```
87
109
 
88
- ```tsx
89
- import { Edge, jsx, Fragment } from '@lengkapp/edge';
110
+ > **Why `export default app`?**
111
+ > The `Edge` instance exposes a `fetch(request, env, executionCtx)` method, which is exactly the Workers module-syntax contract.
90
112
 
91
- const app = new Edge();
113
+ ### JSX pragma
92
114
 
93
- const Card = () => (
94
- <div>card</div>
95
- );
115
+ The framework uses the classic JSX transform. Add these two comments at the top of every file that contains JSX:
96
116
 
97
- const LandingPage = () => (
98
- <>
99
- <h1>hello world</h1>
100
- <Card />
101
- </>
102
- );
103
-
104
- app.get('/', () => <LandingPage />);
105
-
106
- export default app;
117
+ ```js
118
+ /** @jsx jsx */
119
+ /** @jsxFrag Fragment */
120
+ import { jsx, Fragment } from '@lengkapp/edge';
107
121
  ```
108
122
 
109
- If you use TypeScript, `tsconfig.json`:
123
+ That's all the configuration required — no Babel, no tsconfig, no plugin.
110
124
 
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
- }
125
+ ## Routing
126
+
127
+ ```js
128
+ app.get(path, [options], handler);
129
+ app.post(path, [options], handler);
130
+ app.put(path, [options], handler);
131
+ app.delete(path, [options], handler);
132
+ app.patch(path, [options], handler);
133
+ app.options(path, [options], handler);
134
+ app.head(path, [options], handler);
127
135
  ```
128
136
 
129
- `wrangler.jsonc`:
137
+ `options` may be omitted entirely, or passed as the second argument:
130
138
 
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
- }
139
+ ```js
140
+ app.get('/health', (c) => c.text('ok'));
141
+ app.get('/api/users/:id', { cache: { ttl: 60 } }, (c) => c.json({ id: c.params.id }));
138
142
  ```
139
143
 
140
- Or `wrangler.toml`:
144
+ ### Static vs. dynamic paths
141
145
 
142
- ```toml
143
- name = "my-edge-app"
144
- main = "worker.js"
145
- compatibility_date = "2026-09-14"
146
+ ```js
147
+ app.get('/about', handler); // static → Map lookup
148
+ app.get('/users/:id', handler); // dynamic → trie walk
149
+ app.get('/users/:id/posts/:postId', handler);
146
150
  ```
147
151
 
148
- Deploy:
152
+ - Trailing slashes are normalised: `/about/` and `/about` are the same route.
153
+ - Static segments always win over a param at the same position.
154
+ - Params are collected into `ctx.params` (a null-prototype object).
149
155
 
150
- ```bash
151
- wrangler deploy
156
+ ```js
157
+ app.get('/users/:id', (c) => {
158
+ return c.json({ id: c.params.id }); // GET /users/42 → {"id":"42"}
159
+ });
152
160
  ```
153
161
 
154
- ## Server API
155
-
156
- ### Context
162
+ Unmatched requests receive `404 Not Found` as `text/plain`.
157
163
 
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 |
164
+ ## Context API
175
165
 
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`.
166
+ Every handler receives a `Context` instance.
177
167
 
178
- ### Route Options
168
+ ### Properties
179
169
 
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);
170
+ | Property | Type | Description |
171
+ |---|---|---|
172
+ | `c.req` | `Request` | The incoming request |
173
+ | `c.env` | `object` | Worker bindings / environment |
174
+ | `c.executionCtx` | `ExecutionContext` | For `waitUntil()` |
175
+ | `c.params` | `object` | Route parameters |
176
+ | `c.url` | `URL` | Parsed request URL |
177
+ | `c.query` | `URLSearchParams` | `c.url.searchParams` |
178
+ | `c.headers` | `Headers` | Response headers, pre-filled with security defaults |
179
+ | `c.status` | `number` | Default status used by `text`/`json`/`html`/`page` (default `200`) |
180
+
181
+ ### Response helpers
182
+
183
+ ```js
184
+ c.text('hello', 200, { 'X-Foo': 'bar' }); // text/plain; charset=utf-8
185
+ c.json({ ok: true }); // application/json; charset=utf-8
186
+ await c.html(<div>hi</div>); // text/html; charset=utf-8
187
+ await c.page(<html>…</html>); // same, but prepends <!DOCTYPE html> if missing
188
+ c.redirect('/login', 302);
189
+ c.redirect('https://example.com', 301);
186
190
  ```
187
191
 
188
- **Cache:** `{ ttl, staleWhileRevalidate }` — TTL in seconds, default 3600; only applied to GET responses with status 200. `Set-Cookie` is stripped from cached responses.
192
+ `html()` and `page()` accept:
189
193
 
190
- **CORS:** The default is `origin: '*'`. For production, use a strict allowlist:
194
+ - a string (passed through)
195
+ - a JSX node or array of nodes (rendered with `renderToString`)
196
+ - anything else (`String(data)`)
191
197
 
192
- ```ts
193
- app.defaults.cors.origin = ['https://app.example.com'];
194
- ```
198
+ Both are async because component trees may contain async components.
195
199
 
196
- **Compress:** Negotiates gzip or deflate from `Accept-Encoding` using the native `CompressionStream`.
200
+ ### Headers
197
201
 
198
- **Log:** Logs `METHOD URL - STATUS` to the console.
202
+ `c.headers` is a `Headers` instance already containing the base security headers. Mutate it directly before returning a response:
199
203
 
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.
204
+ ```js
205
+ app.get('/custom', (c) => {
206
+ c.headers.set('X-Custom', '1');
207
+ return c.text('ok');
208
+ });
209
+ ```
201
210
 
202
- ## JSX Support
211
+ Or pass per-response overrides as the third argument:
203
212
 
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.
213
+ ```js
214
+ c.json(data, 200, { 'Cache-Control': 'no-store' });
215
+ ```
205
216
 
206
- ```tsx
207
- function Card({ title }) {
208
- return <div class="card"><h2>{title}</h2></div>;
209
- }
217
+ ### Cookies
218
+
219
+ ```js
220
+ // Read
221
+ const session = c.getCookie('session'); // string | null
222
+
223
+ // Write
224
+ c.setCookie('session', token, {
225
+ path: '/',
226
+ httpOnly: true,
227
+ secure: true,
228
+ sameSite: 'Lax',
229
+ maxAge: 3600,
230
+ domain: 'example.com',
231
+ expires: new Date(Date.now() + 3600_000),
232
+ });
210
233
 
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" />]));
234
+ // Delete
235
+ c.deleteCookie('session', { path: '/' });
215
236
 
216
- // Raw strings still work as before.
217
- app.get('/raw', (ctx) => ctx.html('<p>pre-rendered</p>'));
237
+ return c.text('done');
218
238
  ```
219
239
 
220
- Returning a JSX element directly from a handler is also supported — it is treated as an HTML response:
240
+ Cookie names are validated against `__proto__`, `constructor`, `prototype`, and CRLF / `;` / `=` injection.
221
241
 
222
- ```tsx
223
- app.get('/', () => <LandingPage />);
224
- ```
242
+ ## JSX & Server-Side Rendering
225
243
 
226
- `renderToString` is still exported for advanced use cases (for example, embedding rendered HTML inside another response body or email template):
244
+ ### `renderToString(node, ctx?)`
227
245
 
228
- ```tsx
246
+ ```js
229
247
  import { renderToString } from '@lengkapp/edge';
230
248
 
231
- const html = renderToString(<Card title="Hello" />);
232
- ```
233
-
234
- **`renderToString` hardening:**
235
-
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
248
-
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';
249
+ const html = await renderToString(<div>Hello</div>);
250
+ ```
274
251
 
275
- /* ------------------------------------------------------------------ *
276
- * Small helper components (JSX function components) *
277
- * ------------------------------------------------------------------ */
252
+ Accepts `null`, `undefined`, booleans (rendered as `''`), strings, numbers, promises/thenables, arrays, and JSX nodes. All text and attribute values are HTML-escaped.
278
253
 
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>
299
- </html>
300
- );
301
- }
254
+ ### Components
302
255
 
303
- function UserCard(props: { id: string; name: string; admin?: boolean }) {
256
+ ```jsx
257
+ function Card({ title, children }) {
304
258
  return (
305
- <div class="card" data-id={props.id}>
306
- <h2>{props.name}</h2>
307
- {props.admin && <span class="badge">admin</span>}
259
+ <div className="card">
260
+ <h2>{title}</h2>
261
+ {children}
308
262
  </div>
309
263
  );
310
264
  }
311
265
 
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
- );
266
+ app.get('/', (c) => c.page(
267
+ <Card title="Welcome">
268
+ <p>Body text</p>
269
+ </Card>
270
+ ));
271
+ ```
272
+
273
+ ### Fragments
274
+
275
+ ```jsx
276
+ <>
277
+ <li>One</li>
278
+ <li>Two</li>
279
+ </>
280
+ ```
281
+
282
+ ### Async components
283
+
284
+ ```jsx
285
+ async function Weather({ city }) {
286
+ const r = await fetch(`https://api.example.com/weather?q=${city}`);
287
+ const data = await r.json();
288
+ return <span>{data.temp}°C</span>;
320
289
  }
321
290
 
322
- /* ------------------------------------------------------------------ *
323
- * App *
324
- * ------------------------------------------------------------------ */
291
+ app.get('/', (c) => c.page(
292
+ <div>It is <Weather city="Jakarta" /></div>
293
+ ));
294
+ ```
325
295
 
326
- const app = new Edge();
296
+ `renderToString` awaits every node recursively.
327
297
 
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
- };
298
+ ### `useCtx()`
334
299
 
335
- /* ================================================================== *
336
- * Basic routes *
337
- * ================================================================== */
300
+ Call synchronously at the top of a component (before any `await`) to read the active `Context`:
338
301
 
339
- // Plain text
340
- app.get('/health', (ctx) => ctx.text('ok'));
302
+ ```jsx
303
+ import { useCtx } from '@lengkapp/edge';
341
304
 
342
- // JSON with a custom status
343
- app.get('/api/time', (ctx) =>
344
- ctx.json({ now: new Date().toISOString() }, 200)
345
- );
305
+ function UserBadge() {
306
+ const c = useCtx();
307
+ return <span>Route param: {c.params.id}</span>;
308
+ }
309
+ ```
346
310
 
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
- ));
311
+ ```jsx
312
+ async function UserBadge() {
313
+ const c = useCtx(); // ✅ before await
314
+ const data = await loadUser(c.params.id);
315
+ // const c2 = useCtx(); // ❌ throws — not available after await
316
+ return <span>{data.name}</span>;
317
+ }
318
+ ```
356
319
 
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
- );
320
+ The context stack is pushed immediately before a function component is invoked and popped immediately after its synchronous portion returns. JS cannot preempt running synchronous code, so this window is atomic per isolate — no other request can observe a torn stack.
373
321
 
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
- );
322
+ > **Note:** Do not call `useCtx()` from async continuations; capture `c` into a local variable instead.
378
323
 
379
- /* ================================================================== *
380
- * Params, query, cookies *
381
- * ================================================================== */
324
+ ### Prop → attribute mapping
382
325
 
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
- );
391
- });
326
+ | Prop | Rendered as |
327
+ |---|---|
328
+ | `className` / `class` | `class` |
329
+ | `htmlFor` / `for` | `for` |
330
+ | `style={{ fontSize: 12, lineHeight: 1.5 }}` | `style="font-size:12px;line-height:1.5"` |
331
+ | `key`, `ref` | skipped |
332
+ | `children` | skipped (rendered separately) |
333
+ | `on*` (e.g. `onClick`) | skipped — no event handlers in SSR output |
334
+ | boolean attrs (`disabled`, `required`, `checked`, …) | rendered bare when `true`, omitted when `false` |
335
+ | `null` / `undefined` / `false` | omitted |
392
336
 
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
- });
337
+ Numeric style values automatically get `px` appended unless the property is unitless (`opacity`, `z-index`, `line-height`, `flex`, …).
398
338
 
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
- });
339
+ ### Raw HTML
405
340
 
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
- });
341
+ ```jsx
342
+ <div dangerouslySetInnerHTML={{ __html: trustedHtml }} />
343
+ ```
417
344
 
418
- app.get('/logout', (ctx) => {
419
- ctx.deleteCookie('session', { path: '/' });
420
- return ctx.redirect('/');
421
- });
345
+ This bypasses escaping. **Only use it with content you fully control.**
422
346
 
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>
431
- );
432
- });
347
+ ### Sanitisation
433
348
 
434
- /* ================================================================== *
435
- * All HTTP methods *
436
- * ================================================================== */
349
+ - Text and attributes are escaped (`& < > " '`).
350
+ - Tag names must match `[A-Za-z][A-Za-z0-9-]*`.
351
+ - Attribute names are validated; `on*` handlers are stripped.
352
+ - `href`, `src`, `xlink:href`, `action`, `formaction` are rejected if they resolve to `javascript:`, `vbscript:`, or `data:text/html` (whitespace and control characters are stripped before the check).
353
+ - Props named `__proto__`, `constructor`, or `prototype` are ignored.
354
+
355
+ ## Route Options
356
+
357
+ ```js
358
+ app.get('/path', {
359
+ cors: true,
360
+ validate: async (c) => true,
361
+ cache: { ttl: 300, staleWhileRevalidate: 60 },
362
+ compress: true,
363
+ log: true,
364
+ }, handler);
365
+ ```
437
366
 
438
- app.post('/api/echo', async (ctx) => {
439
- const body = await ctx.req.json().catch(() => null);
440
- return ctx.json({ received: body }, 201);
367
+ | Option | Type | Description |
368
+ |---|---|---|
369
+ | `cors` | `boolean \| object` | Enable CORS. `true` uses the global default (`origin: '*'`). |
370
+ | `validate` | `async (c) => boolean` | Runs before the handler. Returning falsy yields `400 Validation failed`. |
371
+ | `cache` | `boolean \| { ttl, staleWhileRevalidate }` | Cache successful GET responses in `caches.default`. Defaults: `ttl = 3600`, `swr = 0`. |
372
+ | `compress` | `boolean` | gzip / deflate the response when the client accepts it. |
373
+ | `log` | `boolean` | Log `METHOD URL - STATUS` to the console. |
374
+
375
+ ### `validate` example
376
+
377
+ ```js
378
+ app.post('/api/items', {
379
+ validate: async (c) => {
380
+ const auth = c.req.headers.get('Authorization');
381
+ return auth === `Bearer ${c.env.API_TOKEN}`;
382
+ },
383
+ }, async (c) => {
384
+ const body = await c.req.json();
385
+ return c.json({ created: true, body }, 201);
441
386
  });
387
+ ```
442
388
 
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 });
389
+ A thrown error inside `validate` is treated as a failure (and logged as a security event).
390
+
391
+ ## Caching
392
+
393
+ ```js
394
+ app.get('/expensive', { cache: { ttl: 600, staleWhileRevalidate: 120 } }, async (c) => {
395
+ const data = await computeSomethingSlow();
396
+ return c.json(data);
446
397
  });
398
+ ```
447
399
 
448
- app.patch('/api/items/:id', (ctx) =>
449
- ctx.json({ patched: ctx.params.id })
450
- );
400
+ - Only `GET` requests are cached.
401
+ - Only `200` responses are stored.
402
+ - The cache key is the full `Request` (URL + method).
403
+ - `Set-Cookie` is stripped from the cached response.
404
+ - A `Cache-Control: max-age=<ttl>[, stale-while-revalidate=<swr>]` header is written.
405
+ - Writes happen in `ctx.executionCtx.waitUntil(...)`, so they never block the response.
406
+ - On a hit, the stored response is returned directly (with CORS and log processing still applied).
451
407
 
452
- app.delete('/api/items/:id', (ctx) =>
453
- ctx.json({ deleted: ctx.params.id }, 200)
454
- );
408
+ ## Compression
455
409
 
456
- app.options('/api/items', (ctx) => ctx.text('', 204));
410
+ ```js
411
+ app.get('/big', { compress: true }, (c) => c.html(hugeMarkup));
412
+ ```
457
413
 
458
- app.head('/api/items', (ctx) => ctx.text('', 200));
414
+ - Skipped when `Content-Type` is not compressible (`text/*`, `application/json`, `application/xml`, `application/javascript`, `application/xhtml`, `application/ld+json`, `application/manifest+json`, `image/svg*`).
415
+ - Skipped when `Content-Length` is known and `< 1024`.
416
+ - Chooses `gzip` first, then `deflate`, based on `Accept-Encoding`.
417
+ - Sets `Content-Encoding` and `Vary: Accept-Encoding`, removes `Content-Length`.
459
418
 
460
- /* ================================================================== *
461
- * Route options: cors, cache, compress, log, validate *
462
- * ================================================================== */
419
+ ## CORS
463
420
 
464
- // CORS with a wildcard origin
465
- app.get(
466
- '/cors-open',
467
- { cors: true, log: true },
468
- (ctx) => ctx.json({ cors: 'wildcard' })
469
- );
421
+ Global default (used when `cors: true`):
470
422
 
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
- );
423
+ ```js
424
+ app.defaults.cors = {
425
+ origin: '*',
426
+ methods: 'GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD',
427
+ };
428
+ ```
483
429
 
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
- );
430
+ Per-route:
493
431
 
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
- );
432
+ ```js
433
+ app.get('/api/public', { cors: true }, handler);
500
434
 
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
- },
435
+ app.get('/api/private', {
436
+ cors: {
437
+ origin: ['https://app.example.com', 'https://admin.example.com'],
438
+ methods: 'GET, POST',
439
+ headers: 'Content-Type, Authorization',
509
440
  },
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',
520
- };
441
+ }, handler);
521
442
 
522
- app.get('/everything', everythingOptions, (ctx) =>
523
- ctx.html(
524
- <Layout title="Everything">
525
- <h1>All options at once</h1>
526
- </Layout>
527
- )
528
- );
443
+ app.get('/api/exact', { cors: { origin: 'https://example.com' } }, handler);
444
+ ```
529
445
 
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>
446
+ | `origin` value | Result |
447
+ |---|---|
448
+ | `'*'` | `Access-Control-Allow-Origin: *` |
449
+ | `string` | Echoes the request origin only if it matches exactly, plus `Vary: Origin` and `Allow-Credentials: true` |
450
+ | `string[]` | Echoes the request origin only if it is in the list, plus `Vary: Origin` and `Allow-Credentials: true` |
548
451
 
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} />
452
+ `Access-Control-Max-Age: 86400` is always set. Remember to register an `OPTIONS` route if you need preflight handling.
554
453
 
555
- {/* Boolean attributes: true emits the bare attribute */}
556
- <input type="checkbox" checked readOnly />
557
- <button disabled>Nope</button>
454
+ ## Security
558
455
 
559
- {/* Void elements self-close */}
560
- <img src="/logo.png" alt="logo" />
561
- <br />
562
- <hr />
456
+ ### Base headers (always applied)
563
457
 
564
- {/* dangerouslySetInnerHTML */}
565
- <div dangerouslySetInnerHTML={{ __html: '<b>trusted</b>' }} />
458
+ ```text
459
+ X-Content-Type-Options: nosniff
460
+ X-Frame-Options: DENY
461
+ Referrer-Policy: strict-origin-when-cross-origin
462
+ Permissions-Policy: geolocation=(), microphone=(), camera=()
463
+ ```
566
464
 
567
- {/* Fragments */}
568
- <>
569
- <p>Fragment child A</p>
570
- <p>Fragment child B</p>
571
- </>
465
+ ### Extra headers
572
466
 
573
- {/* Arrays of JSX */}
574
- {[<span key="a">A</span>, <span key="b">B</span>, <span key="c">C</span>]}
467
+ ```js
468
+ app.security.extraHeaders = {
469
+ 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
470
+ 'Content-Security-Policy': "default-src 'self'",
471
+ };
472
+ ```
575
473
 
576
- {/* Escaping: user-supplied strings are escaped */}
577
- <p>{'<script>alert(1)</script>'}</p>
474
+ ### Security event logging
578
475
 
579
- {/* Dangerous URLs are dropped */}
580
- <a href="javascript:alert(1)">nope</a>
581
- <a href="https://example.com">ok</a>
476
+ Enabled by default. Events are sampled to at most one per `(event, ip, path)` per 60 seconds.
582
477
 
583
- {/* Numbers are stringified and escaped */}
584
- <p>Count: {42}</p>
478
+ ```js
479
+ app.security.logSecurityEvents = false; // disable
480
+ ```
585
481
 
586
- {/* null / undefined / booleans render nothing */}
587
- <p>{null}{undefined}{false}{true}</p>
588
- </Layout>
589
- )
590
- );
482
+ Logged events: `validation_error`, `handler_error`. Each entry is a JSON line containing timestamp, event, detail, IP (`CF-Connecting-IP`), method, path, and user agent.
591
483
 
592
- /* ================================================================== *
593
- * renderToString() standalone *
594
- * ================================================================== */
484
+ ## Scheduled Handler
595
485
 
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
- });
486
+ The `Edge` class stores a cron handler via `app.scheduled(fn)`. Because the default export must expose `scheduled` as a function, wrap the instance:
605
487
 
606
- /* ================================================================== *
607
- * Scheduled handler *
608
- * ================================================================== */
488
+ ```js
489
+ const app = new Edge();
609
490
 
610
491
  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.
492
+ console.log('cron fired at', event.scheduledTime);
613
493
  });
614
494
 
615
- /* ================================================================== *
616
- * Cloudflare Workers entry points *
617
- * ================================================================== */
618
-
619
495
  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),
496
+ fetch: (request, env, executionCtx) => app.fetch(request, env, executionCtx),
497
+ scheduled: (event, env, executionCtx) => app.scheduledHandler(event, env, executionCtx),
624
498
  };
625
499
  ```
626
500
 
627
- ### Feature → route cheat-sheet
501
+ ## Client-Side Partial Updates
628
502
 
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)
503
+ The framework ships a tiny IIFE (`edge.client.js`) that turns HTML attributes into fetch-and-swap behaviour. No build step, no framework, no hydration.
504
+
505
+ ### Including it
506
+
507
+ Serve it as a static asset:
657
508
 
658
509
  ```html
659
- <script src="https://cdn.example.com/edge-client.min.js"></script>
510
+ <script src="/edge.client.js" defer></script>
511
+ ```
512
+
513
+ …or inline it at the end of `<body>`:
514
+
515
+ ```html
516
+ <script>/* contents of edge.client.js */</script>
660
517
  ```
661
518
 
662
519
  ### Attributes
663
520
 
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). |
680
-
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.
682
-
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).
521
+ | Attribute | Value | Description |
522
+ |---|---|---|
523
+ | `_get` | URL | Perform a `GET` request to this URL. |
524
+ | `_post` | URL | Perform a `POST` request to this URL. |
525
+ | `_trigger` | `load` \| `visible` \| `click` \| `submit` \| `change` \| `input` \| `keyup` \| `dblclick` | When to fire. Default: `submit` on a `<form>`, `click` on anything else. |
526
+ | `_in` | CSS selector | Element whose `innerHTML` receives the response. Default: the element itself. |
527
+ | `_form` | CSS selector | A `<form>` whose `FormData` becomes the URL-encoded POST body. |
528
+ | `_json` | CSS selector | A `<form>` or input whose values become a JSON POST body. |
529
+ | `_error` | CSS selector | Element that receives the response body when the status is not 2xx. |
530
+
531
+ Exactly one of `_get` / `_post` is required on an element for it to be "active".
532
+
533
+ ### Triggers
534
+
535
+ - **`click`** (default for non-forms) — fires on click.
536
+ - **`submit`** (default for `<form>`) — fires on submit; `preventDefault()` is called automatically.
537
+ - **`load`** — fires as soon as the element is scanned (page load or injected into the DOM).
538
+ - **`visible`** — fires when the element scrolls into the viewport (`IntersectionObserver`), then unobserves.
539
+ - **`change` / `input` / `keyup` / `dblclick`** — must be opted into explicitly with `_trigger="…"`.
540
+
541
+ Events are attached in the capture phase on `document`, so they survive DOM replacement and work with dynamically added content.
684
542
 
685
543
  ### Examples
686
544
 
545
+ **Refresh a fragment**
546
+
687
547
  ```html
688
- <!-- replace the children of #posts with the response -->
689
- <button _get="/more-posts" _in="#posts">Load More</button>
548
+ <button _get="/api/time" _in="#clock">Refresh</button>
549
+ <div id="clock">—</div>
550
+ ```
551
+
552
+ ```js
553
+ app.get('/api/time', (c) => c.html(<span>{new Date().toISOString()}</span>));
554
+ ```
690
555
 
691
- <!-- replace this element with the response -->
692
- <div _get="/user-profile" _out="this"></div>
556
+ **Submit a form and replace a list**
693
557
 
694
- <!-- POST JSON built from form fields, replace the children of #status -->
695
- <button _post="/login" _json="username,password" _in="#status">Login</button>
558
+ ```html
559
+ <form id="add-form" _post="/todos" _form="#add-form" _in="#todo-list" _error="#form-error">
560
+ <input name="text" required />
561
+ <button>Add</button>
562
+ </form>
563
+ <p id="form-error"></p>
564
+
565
+ <ul id="todo-list">
566
+ <li>Existing item</li>
567
+ </ul>
568
+ ```
696
569
 
697
- <!-- POST a whole form -->
698
- <form id="signup">…</form>
699
- <button _post="/signup" _form="#signup" _in="#result">Sign up</button>
570
+ ```js
571
+ app.post('/todos', async (c) => {
572
+ const fd = await c.req.formData();
573
+ const text = String(fd.get('text') || '').trim();
574
+ if (!text) return c.text('<em>Text is required</em>', 422);
575
+ return c.html(<li>{text}</li>);
576
+ });
577
+ ```
700
578
 
701
- <!-- fetch lazily when the element scrolls into view -->
702
- <div _get="/lazy" _in="this" _trigger="visible"></div>
579
+ **POST JSON from a single input**
703
580
 
704
- <!-- skeleton loader with a 5-second timeout -->
705
- <div _get="/feed" _in="this" _loader="skeleton" _timeout="5000"></div>
581
+ ```html
582
+ <input id="q" name="q" _post="/api/search" _json="#q" _trigger="keyup" _in="#results" />
583
+ <div id="results"></div>
584
+ ```
706
585
 
707
- <!-- _toast if non 2xx -->
708
- <div _get="/feed-error" _in="this" _toast="#toast"></div>
586
+ **Load on scroll into view**
709
587
 
710
- <!-- same-tab navigation with a device id -->
711
- <a href="/dashboard" _go _id>Dashboard</a>
588
+ ```html
589
+ <div _get="/api/feed/page/2" _trigger="visible" _in="this">
590
+ Loading…
591
+ </div>
592
+ ```
712
593
 
713
- <!-- new-tab navigation -->
714
- <a href="/docs" _open>Docs</a>
594
+ **Fire on page load**
715
595
 
716
- <!-- POST with X-DeviceId header -->
717
- <button _post="/like" _id _in="#card-3">Like</button>
596
+ ```html
597
+ <div _get="/api/notifications" _trigger="load" _in="#bell"></div>
718
598
  ```
719
599
 
720
- ### Triggers
600
+ ### Request headers
721
601
 
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.
602
+ Every request automatically includes:
725
603
 
726
- ### How Content Is Inserted
604
+ | Header | Source |
605
+ |---|---|
606
+ | `x-csrf-token` | `<meta name="_csrf" content="…">` |
607
+ | `x-auth-token` | `<meta name="_auth" content="…">` |
727
608
 
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.
609
+ ```html
610
+ <meta name="_csrf" content="<%= csrfToken %>" />
611
+ <meta name="_auth" content="<%= authToken %>" />
612
+ ```
738
613
 
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.
614
+ Missing metas simply produce empty strings.
740
615
 
741
- ### Loader & Error States
616
+ ### Body encoding
742
617
 
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.
618
+ - `_form` → `application/x-www-form-urlencoded` (built from `FormData`)
619
+ - `_json` with a `<form>` → JSON object of all fields
620
+ - `_json` with a single input → `{ "<name|id>": value }`
621
+ - Neither → no body
749
622
 
750
- ### View Transitions
623
+ ### Response handling
751
624
 
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`.
625
+ - `response.ok` → swap `_in` target with the response text
626
+ - `!response.ok` and `_error` present → swap `_error` target with the response text
627
+ - `!response.ok` and no `_error` → the response is silently discarded
753
628
 
754
- ## Device Fingerprint
629
+ After a swap, any `<script>` and `<style>` elements in the inserted HTML are cloned and re-inserted so they execute/apply, then the new subtree is scanned for active elements. A `MutationObserver` on `document.documentElement` does the same for any DOM added by other means.
755
630
 
756
- Add `_id` to any `_get` / `_post` / `_go` / `_open` element to attach a stable device identifier.
631
+ ### Auto IDs
757
632
 
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.
633
+ When an element uses the default `_in` (itself) and has no `id`, the runtime assigns one (`_g1`, `_g2`, …) so the swap target can be resolved.
759
634
 
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.
635
+ ## Complete Example
761
636
 
762
- **Delivery.**
637
+ **`index.js`**
763
638
 
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`).
639
+ ```jsx
640
+ /** @jsx jsx */
641
+ /** @jsxFrag Fragment */
642
+ import { Edge, jsx, Fragment, useCtx } from '@lengkapp/edge';
766
643
 
767
- **Fallback.** If `crypto.subtle` is unavailable (non-secure context), a djb2 hash of the same signals is used, with lower collision resistance.
644
+ const app = new Edge();
768
645
 
769
- Without `_id`, no fingerprint is computed, no header is set, and no URL parameter is added.
646
+ /* ------------------------------------------------------------------ *
647
+ * Data *
648
+ * ------------------------------------------------------------------ */
649
+ let seq = 0;
650
+ const todos = new Map();
770
651
 
771
- ## CSRF Protection
652
+ function addTodo(text) {
653
+ const id = String(++seq);
654
+ todos.set(id, { id, text, done: false });
655
+ return todos.get(id);
656
+ }
772
657
 
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:
658
+ addTodo('Read the docs');
659
+ addTodo('Deploy to production');
774
660
 
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
- ```
661
+ /* ------------------------------------------------------------------ *
662
+ * Components *
663
+ * ------------------------------------------------------------------ */
664
+ function Layout({ title, children }) {
665
+ return (
666
+ <html lang="en">
667
+ <head>
668
+ <meta charset="utf-8" />
669
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
670
+ <meta name="_csrf" content="demo-csrf-token" />
671
+ <title>{title}</title>
672
+ <style>{`
673
+ body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 3rem auto; }
674
+ li { padding: .25rem 0; }
675
+ `}</style>
676
+ </head>
677
+ <body>
678
+ <nav><a href="/">Home</a> · <a href="/todos">Todos</a></nav>
679
+ <main>{children}</main>
680
+ <script src="/edge.client.js" defer></script>
681
+ </body>
682
+ </html>
683
+ );
684
+ }
783
685
 
784
- For `_form`-based submissions, read the field from the parsed form data using the same pattern.
686
+ function TodoItem({ todo }) {
687
+ return <li id={'todo-' + todo.id}>{todo.text}</li>;
688
+ }
785
689
 
786
- ## Security Controls
690
+ function TodoList() {
691
+ return <>{[...todos.values()].map((t) => <TodoItem todo={t} />)}</>;
692
+ }
787
693
 
788
- ```ts
789
- const app = new Edge();
694
+ /* ------------------------------------------------------------------ *
695
+ * Routes *
696
+ * ------------------------------------------------------------------ */
697
+ app.get('/', (c) =>
698
+ c.page(
699
+ <Layout title="Home">
700
+ <h1>Hello{nameSuffix(c)}</h1>
701
+ <p>This page was rendered on the edge in {(0.01).toFixed(2)} ms.</p>
790
702
 
791
- app.defaults.cors.origin = ['https://app.example.com'];
703
+ <button _get="/api/time" _in="#clock">Refresh time</button>
704
+ <p id="clock">—</p>
792
705
 
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
- };
799
- ```
706
+ <div _get="/api/greeting" _trigger="visible" _in="this">
707
+ Scroll-triggered content loading…
708
+ </div>
709
+ </Layout>
710
+ )
711
+ );
800
712
 
801
- ## OWASP 2025 Coverage
713
+ function nameSuffix(c) {
714
+ const n = c.query.get('name');
715
+ return n ? `, ${n}` : '';
716
+ }
802
717
 
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
- });
718
+ app.get('/api/time', (c) => c.html(<strong>{new Date().toISOString()}</strong>));
822
719
 
823
- export default {
824
- fetch: (req, env, ctx) => app.fetch(req, env, ctx),
825
- scheduled: (event, env, ctx) => app.scheduledHandler?.(event, env, ctx),
826
- };
827
- ```
720
+ app.get('/api/greeting', (c) =>
721
+ c.html(<p>👋 Loaded lazily when it became visible.</p>)
722
+ );
828
723
 
829
- ## Configuration
724
+ app.get('/todos', (c) =>
725
+ c.page(
726
+ <Layout title="Todos">
727
+ <h1>Todos</h1>
830
728
 
831
- The `Edge` constructor takes no arguments. Behaviour is configured through public properties and route options:
729
+ <form id="add" _post="/todos" _form="#add" _in="#list" _error="#err">
730
+ <input name="text" placeholder="What needs doing?" required />
731
+ <button>Add</button>
732
+ </form>
733
+ <p id="err"></p>
832
734
 
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` |
735
+ <ul id="list">
736
+ <TodoList />
737
+ </ul>
738
+ </Layout>
739
+ )
740
+ );
839
741
 
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
- ```
742
+ app.post('/todos', async (c) => {
743
+ const fd = await c.req.formData();
744
+ const text = String(fd.get('text') || '').trim();
745
+ if (!text) return c.text('<em>Please enter some text.</em>', 422);
746
+ addTodo(text);
747
+ return c.html(<TodoList />);
748
+ });
749
+
750
+ /* Dynamic params + JSON + caching */
751
+ app.get(
752
+ '/api/todos/:id',
753
+ { cache: { ttl: 30, staleWhileRevalidate: 300 }, cors: true, log: true },
754
+ (c) => {
755
+ const todo = todos.get(c.params.id);
756
+ if (!todo) return c.json({ error: 'Not found' }, 404);
757
+ return c.json(todo);
758
+ }
759
+ );
760
+
761
+ /* Cookie round-trip */
762
+ app.get('/theme/:name', (c) => {
763
+ c.setCookie('theme', c.params.name, {
764
+ path: '/',
765
+ httpOnly: true,
766
+ secure: true,
767
+ sameSite: 'Lax',
768
+ maxAge: 60 * 60 * 24 * 365,
769
+ });
770
+ return c.redirect('/');
771
+ });
846
772
 
847
- ## Performance
773
+ app.get('/api/theme', (c) => c.json({ theme: c.getCookie('theme') || 'light' }));
848
774
 
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:
775
+ /* Compressed, validated, cached API */
776
+ app.get(
777
+ '/api/large',
778
+ {
779
+ compress: true,
780
+ cache: { ttl: 120 },
781
+ validate: async (c) => c.req.headers.get('X-Api-Key') === c.env.API_KEY,
782
+ },
783
+ (c) => c.json({ items: Array.from({ length: 500 }, (_, i) => ({ i })) })
784
+ );
850
785
 
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 |
786
+ /* ------------------------------------------------------------------ *
787
+ * Export *
788
+ * ------------------------------------------------------------------ */
789
+ export default {
790
+ fetch: (request, env, executionCtx) => app.fetch(request, env, executionCtx),
791
+ scheduled: (event, env, executionCtx) =>
792
+ app.scheduledHandler?.(event, env, executionCtx),
793
+ };
794
+ ```
857
795
 
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.
796
+ ## License
859
797
 
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.
798
+ ```text
799
+ LengkApp Edge License
861
800
 
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.
801
+ Copyright (c) LengkApp — Yasir Haris
802
+ Contact: yh@lengk.app / yasir.haris@gmail.com
863
803
 
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.
804
+ Permission is granted to use, copy, and distribute this software free of charge,
805
+ including for commercial purposes, provided that:
865
806
 
866
- ## Security Posture Summary
807
+ 1. The Software may not be modified, adapted, or altered in any way without
808
+ prior written permission from the copyright holder.
867
809
 
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 |
810
+ 2. Redistribution, in whole or in part, must retain the unmodified LICENSE file
811
+ and the copyright notice above.
878
812
 
879
- ## License
813
+ 3. No right is granted to use the LengkApp name, brand, or trademarks without
814
+ separate written permission.
880
815
 
881
- MIT — see [LICENSE](./LICENSE) for the full text.
882
- © LengkApp — Yasir Haris
816
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
817
+ IMPLIED. IN NO EVENT SHALL THE AUTHOR OR COPYRIGHT HOLDER BE LIABLE FOR ANY
818
+ CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM, OUT OF, OR IN CONNECTION WITH
819
+ THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
820
+ ```