@lengkapp/edge 0.0.38 → 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 +795 -320
- package/dist/README.md +795 -320
- package/dist/client.js +1 -1
- package/dist/server.d.ts +161 -79
- package/dist/server.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,438 +1,913 @@
|
|
|
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](https://hono.dev), `@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 Controls](#security-controls).
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
## Table of Contents
|
|
11
10
|
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
39
|
+
### Client
|
|
32
40
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
50
|
+
### Security
|
|
49
51
|
|
|
50
|
-
-
|
|
51
|
-
-
|
|
52
|
-
-
|
|
53
|
-
-
|
|
54
|
-
-
|
|
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
|
-
|
|
58
|
+
## Installation
|
|
57
59
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
139
|
+
Deploy:
|
|
69
140
|
|
|
70
141
|
```bash
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
211
|
+
Strict CORS allowlist:
|
|
106
212
|
|
|
107
|
-
```
|
|
108
|
-
|
|
213
|
+
```ts
|
|
214
|
+
app.defaults.cors.origin = ['https://app.example.com'];
|
|
215
|
+
```
|
|
109
216
|
|
|
110
|
-
|
|
217
|
+
## JSX Support
|
|
111
218
|
|
|
112
|
-
|
|
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
|
-
|
|
116
|
-
|
|
221
|
+
```tsx
|
|
222
|
+
function Card({ title }) {
|
|
223
|
+
return <div class="card"><h2>{title}</h2></div>;
|
|
224
|
+
}
|
|
117
225
|
|
|
118
|
-
|
|
119
|
-
<
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
</form>
|
|
231
|
+
// Raw strings still work as before.
|
|
232
|
+
app.get('/raw', (ctx) => ctx.html('<p>pre-rendered</p>'));
|
|
233
|
+
```
|
|
125
234
|
|
|
126
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
Submit (JSON)
|
|
135
|
-
</div>
|
|
237
|
+
```tsx
|
|
238
|
+
app.get('/', () => <LandingPage />);
|
|
239
|
+
```
|
|
136
240
|
|
|
137
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
<
|
|
246
|
+
const html = await renderToString(<Card title="Hello" />);
|
|
148
247
|
```
|
|
149
248
|
|
|
150
|
-
|
|
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
|
-
|
|
267
|
+
A single file that exercises every server feature.
|
|
155
268
|
|
|
156
269
|
```tsx
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
351
|
+
/* ------------------------------------------------------------------ *
|
|
352
|
+
* App *
|
|
353
|
+
* ------------------------------------------------------------------ */
|
|
163
354
|
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
452
|
+
app.get('/logout', (ctx) => {
|
|
453
|
+
ctx.deleteCookie('session', { path: '/' });
|
|
454
|
+
return ctx.redirect('/');
|
|
455
|
+
});
|
|
194
456
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
616
|
+
/* ================================================================== *
|
|
617
|
+
* Scheduled handler *
|
|
618
|
+
* ================================================================== */
|
|
260
619
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
282
|
-
|
|
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
|
-
|
|
673
|
+
### Attributes
|
|
287
674
|
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
694
|
+
### Examples
|
|
295
695
|
|
|
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 |
|
|
696
|
+
```html
|
|
697
|
+
<!-- replace the children of #posts with the response -->
|
|
698
|
+
<button _get="/more-posts" _in="#posts">Load More</button>
|
|
306
699
|
|
|
307
|
-
|
|
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
|
-
|
|
706
|
+
<!-- POST a whole form -->
|
|
707
|
+
<form id="signup">…</form>
|
|
708
|
+
<button _post="/signup" _form="#signup" _in="#result">Sign up</button>
|
|
312
709
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
```
|
|
710
|
+
<!-- fetch lazily when the element scrolls into view -->
|
|
711
|
+
<div _get="/lazy" _in="#feed" _trigger="visible"></div>
|
|
316
712
|
|
|
317
|
-
|
|
713
|
+
<!-- same-tab navigation -->
|
|
714
|
+
<a href="/dashboard" _go>Dashboard</a>
|
|
318
715
|
|
|
319
|
-
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
##
|
|
792
|
+
## CSRF Protection
|
|
337
793
|
|
|
338
|
-
`
|
|
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
|
-
```
|
|
341
|
-
name =
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
812
|
+
When the meta tag is absent, the header is sent with an empty value.
|
|
365
813
|
|
|
366
|
-
|
|
814
|
+
## Security Controls
|
|
367
815
|
|
|
368
|
-
```
|
|
369
|
-
|
|
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
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
window.deviceId = () => myStableFingerprint();
|
|
388
|
-
```
|
|
859
|
+
The `Edge` constructor takes no arguments. Behaviour is configured through public properties and route options:
|
|
389
860
|
|
|
390
|
-
|
|
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
|
-
```
|
|
393
|
-
app.cors = [
|
|
394
|
-
app.
|
|
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
|
-
|
|
875
|
+
## Performance
|
|
398
876
|
|
|
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.
|
|
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
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
892
|
+
## Build
|
|
413
893
|
|
|
414
|
-
|
|
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
|
-
|
|
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
|
-
|
|
423
|
-
|
|
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
|
-
|
|
426
|
-
this LICENSE file unmodified and include the copyright notice above.
|
|
909
|
+
## License
|
|
427
910
|
|
|
428
|
-
|
|
429
|
-
LengkApp name, brand, or trademarks without separate written
|
|
430
|
-
permission.
|
|
911
|
+
MIT — see [LICENSE](./LICENSE) for the full text.
|
|
431
912
|
|
|
432
|
-
|
|
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
|