@lengkapp/edge 0.0.39 → 0.0.40

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