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