@lengkapp/edge 0.0.35 → 0.0.37
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 +319 -759
- package/dist/README.md +319 -759
- package/dist/client.d.ts +109 -0
- package/dist/client.js +29 -0
- package/dist/server.d.ts +121 -0
- package/dist/server.js +29 -0
- package/package.json +12 -10
- package/dist/edge-client.d.ts +0 -1
- package/dist/edge-client.js +0 -33
- package/dist/edge-server.d.ts +0 -235
- package/dist/edge-server.js +0 -33
package/README.md
CHANGED
|
@@ -1,878 +1,438 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Edge Libraries
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Dependency-free edge runtime — HonoJS + htmx inspired. Used by **lengkapp**.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
A tiny, zero-dependency toolkit that gives you:
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
- **`client.js`** — browser runtime driven by HTML attributes (`_get`, `_post`, `_go`, …)
|
|
8
|
+
- **`server.js`** — Cloudflare Worker router with JSX, no bundler required
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
## Table of Contents
|
|
12
|
-
|
|
13
|
-
- [Features](#features)
|
|
14
|
-
- [Installation](#installation)
|
|
15
|
-
- [Quick Start](#quick-start)
|
|
16
|
-
- [Server API](#server-api)
|
|
17
|
-
- [JSX Support](#jsx-support)
|
|
18
|
-
- [Full Example](#full-example)
|
|
19
|
-
- [Client (Declarative Partial Updates)](#client-declarative-partial-updates)
|
|
20
|
-
- [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)
|
|
29
|
-
|
|
30
|
-
---
|
|
31
|
-
|
|
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**
|
|
10
|
+
Small code, maximum security, no external runtime dependencies.
|
|
42
11
|
|
|
43
|
-
|
|
12
|
+
## Contents
|
|
44
13
|
|
|
45
|
-
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
-
|
|
14
|
+
1. [Features](#1-features)
|
|
15
|
+
2. [Install](#2-install)
|
|
16
|
+
3. [Project Layout](#3-project-layout)
|
|
17
|
+
4. [Client Usage](#4-client-usage)
|
|
18
|
+
5. [Server Usage](#5-server-usage)
|
|
19
|
+
6. [Full Example (sample.tsx)](#6-full-example-sampletsx)
|
|
20
|
+
7. [Theming (Dark / Light)](#7-theming-dark--light)
|
|
21
|
+
8. [Icons (Inline Lucide)](#8-icons-inline-lucide-svg)
|
|
22
|
+
9. [Build](#9-build)
|
|
23
|
+
10. [Deploy (Wrangler)](#10-deploy-wrangler)
|
|
24
|
+
11. [Security](#11-security)
|
|
25
|
+
12. [License](#12-license)
|
|
54
26
|
|
|
55
|
-
|
|
27
|
+
---
|
|
56
28
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
29
|
+
## 1. Features
|
|
30
|
+
|
|
31
|
+
### `client.js` (browser)
|
|
32
|
+
|
|
33
|
+
| Attribute | Description |
|
|
34
|
+
|------------|--------------|
|
|
35
|
+
| `_get` | `fetch(url)` GET |
|
|
36
|
+
| `_post` | `fetch(url)` POST, needs `_form` or `_json` |
|
|
37
|
+
| `_go` | Navigate |
|
|
38
|
+
| `_open` | Open in new tab |
|
|
39
|
+
| `_in` | Place response using `innerHTML` |
|
|
40
|
+
| `_out` | Place response using `outerHTML` |
|
|
41
|
+
| `_before` | Place response before target |
|
|
42
|
+
| `_after` | Place response after target |
|
|
43
|
+
| `_form` | FormData source selector (for `_post`) |
|
|
44
|
+
| `_json` | JSON source selector list (for `_post`) |
|
|
45
|
+
| `_trigger` | `"click"` \| `"load"` \| `"visible"` (default `"click"`) |
|
|
46
|
+
| `_id` | Send `X-DeviceId` header (GET/POST only) |
|
|
47
|
+
|
|
48
|
+
**Automatic behavior:**
|
|
49
|
+
|
|
50
|
+
- Always sends `X-CSRF-Token` from `<meta name="_csrf" content="...">`.
|
|
51
|
+
- Sends `X-DeviceId` from `deviceId()` when `_id` is present.
|
|
52
|
+
- `MutationObserver` re-attaches handlers to injected elements.
|
|
53
|
+
- Loader + skeleton + retry/error UI injected around the target (cancelable via `AbortController` on repeat requests).
|
|
54
|
+
- Uses `IntersectionObserver` for `_trigger="visible"`.
|
|
55
|
+
|
|
56
|
+
### `server.js` (Cloudflare Worker)
|
|
57
|
+
|
|
58
|
+
- Hono-style router: `app.get` / `post` / `put` / `delete` / `options`
|
|
59
|
+
- Path params: `"/:lang/:page"`
|
|
60
|
+
- JSX without a bundler — works with `wrangler dev` / `deploy --minify`
|
|
61
|
+
- Per-route config: `{ valid, auth }` for CSRF + cookie auth
|
|
62
|
+
- Response helpers: `c.text`, `c.json`, `c.html`, `c.page`, `c.redirect`
|
|
63
|
+
- CORS allowlist (exact hosts or `*.example.com`)
|
|
64
|
+
- Extra response headers via `app.headers`
|
|
62
65
|
|
|
63
66
|
---
|
|
64
67
|
|
|
65
|
-
##
|
|
68
|
+
## 2. Install
|
|
66
69
|
|
|
67
70
|
```bash
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
`worker.js`:
|
|
74
|
-
|
|
75
|
-
```ts
|
|
76
|
-
import { Edge } from '@lengkapp/edge';
|
|
77
|
-
|
|
78
|
-
const app = new Edge();
|
|
79
|
-
|
|
80
|
-
app.get('/', (ctx) => ctx.text('Hello World!'));
|
|
81
|
-
app.get('/users/:id', (ctx) => ctx.json({ id: ctx.params.id }));
|
|
82
|
-
|
|
83
|
-
export default app;
|
|
84
|
-
```
|
|
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;
|
|
107
|
-
```
|
|
108
|
-
|
|
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
|
-
}
|
|
71
|
+
git clone <this repo>
|
|
72
|
+
cd edge-libraries
|
|
73
|
+
npm install
|
|
74
|
+
npm run build # → dist/
|
|
75
|
+
npm run dev #trying sample.tsx
|
|
127
76
|
```
|
|
128
77
|
|
|
129
|
-
|
|
78
|
+
Requires Node 18+ (built and tested on Node 20+).
|
|
130
79
|
|
|
131
|
-
|
|
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
|
-
```
|
|
139
|
-
|
|
140
|
-
Or `wrangler.toml`:
|
|
141
|
-
|
|
142
|
-
```toml
|
|
143
|
-
name = "my-edge-app"
|
|
144
|
-
main = "worker.js"
|
|
145
|
-
compatibility_date = "2026-09-14"
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
Deploy:
|
|
80
|
+
---
|
|
149
81
|
|
|
150
|
-
|
|
151
|
-
wrangler deploy
|
|
152
|
-
```
|
|
82
|
+
## 3. Project Layout
|
|
153
83
|
|
|
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
84
|
```
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
85
|
+
client.js browser runtime (source)
|
|
86
|
+
client.d.ts typings for the client directives
|
|
87
|
+
server.js worker runtime + JSX factory (source)
|
|
88
|
+
server.d.ts typings for Edge, Context, JSX
|
|
89
|
+
sample.tsx full demo (routing, languages, theme toggle)
|
|
90
|
+
build.mjs esbuild + terser bundler → dist/
|
|
91
|
+
package.json dev deps: esbuild, terser
|
|
92
|
+
tsconfig.json JSX: react / jsxFactory: jsx / jsxFragmentFactory: Fragment
|
|
93
|
+
LICENSE MIT
|
|
94
|
+
|
|
95
|
+
dist/ build output
|
|
96
|
+
client.js minified + banner
|
|
97
|
+
server.js minified + banner
|
|
98
|
+
client.d.ts
|
|
99
|
+
server.d.ts
|
|
100
|
+
LICENSE
|
|
194
101
|
```
|
|
195
102
|
|
|
196
|
-
|
|
103
|
+
---
|
|
197
104
|
|
|
198
|
-
|
|
105
|
+
## 4. Client Usage
|
|
199
106
|
|
|
200
|
-
|
|
107
|
+
```html
|
|
108
|
+
<meta name="_csrf" content="...">
|
|
201
109
|
|
|
202
|
-
|
|
110
|
+
<div id="page">loading</div>
|
|
203
111
|
|
|
204
|
-
|
|
112
|
+
<!-- GET, replace inner HTML of #page, fire on click -->
|
|
113
|
+
<div _get="/data" _in="#page" _trigger="click">Load</div>
|
|
205
114
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
return <div class="card"><h2>{title}</h2></div>;
|
|
209
|
-
}
|
|
115
|
+
<!-- GET on page load, send X-DeviceId -->
|
|
116
|
+
<div _get="/stats" _in="#stats" _trigger="load" _id="1"></div>
|
|
210
117
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
app.get('/heading', (ctx) => ctx.html(<h1>hello</h1>));
|
|
214
|
-
app.get('/list', (ctx) => ctx.html([<Card title="A" />, <Card title="B" />]));
|
|
118
|
+
<!-- GET only when scrolled into view -->
|
|
119
|
+
<div _get="/more" _in="#list" _trigger="visible"></div>
|
|
215
120
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
121
|
+
<form id="myForm" onSubmit="return false;">
|
|
122
|
+
<input type="text" id="emailInput" name="email" />
|
|
123
|
+
<input type="password" id="passwordInput" name="password" />
|
|
124
|
+
</form>
|
|
219
125
|
|
|
220
|
-
|
|
126
|
+
<!-- POST multipart/form-data from #myForm -->
|
|
127
|
+
<div _post="/by-form" _in="#page" _trigger="click" _form="#myForm">
|
|
128
|
+
Submit (FORM)
|
|
129
|
+
</div>
|
|
221
130
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
131
|
+
<!-- POST application/json from selected inputs -->
|
|
132
|
+
<div _post="/by-json" _in="#page" _trigger="click"
|
|
133
|
+
_json="#emailInput,#passwordInput">
|
|
134
|
+
Submit (JSON)
|
|
135
|
+
</div>
|
|
225
136
|
|
|
226
|
-
|
|
137
|
+
<!-- Navigate / new tab -->
|
|
138
|
+
<div _go="/home" _trigger="click">Go home</div>
|
|
139
|
+
<div _open="https://example.com" _trigger="click">Open example</div>
|
|
227
140
|
|
|
228
|
-
|
|
229
|
-
|
|
141
|
+
<!-- Placement variants -->
|
|
142
|
+
<div _get="/x" _in="#target"> innerHTML </div>
|
|
143
|
+
<div _get="/x" _out="#target"> outerHTML </div>
|
|
144
|
+
<div _get="/x" _before="#target"> before </div>
|
|
145
|
+
<div _get="/x" _after="#target"> after </div>
|
|
230
146
|
|
|
231
|
-
|
|
147
|
+
<script src="/client.js"></script>
|
|
232
148
|
```
|
|
233
149
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
- Tag names must match `^[A-Za-z][A-Za-z0-9-]*$`.
|
|
237
|
-
- Attribute names must match `^[A-Za-z_:][A-Za-z0-9_:.-]*$`.
|
|
238
|
-
- `on*` attributes never serialize.
|
|
239
|
-
- `href` / `src` / `action` / `formaction` / `xlink:href` values beginning with `javascript:`, `vbscript:`, or `data:text/html` are stripped.
|
|
240
|
-
- Prototype keys (`__proto__`, `constructor`, `prototype`) are rejected.
|
|
241
|
-
- All string values are HTML-escaped.
|
|
242
|
-
- **Style objects.** `style={{ backgroundColor: 'tomato', padding: 12 }}` is emitted as `style="background-color:tomato;padding:12px"`. Numeric values are suffixed with `px` unless the property is unitless (`opacity`, `lineHeight`, `zIndex`, `flex`, …).
|
|
243
|
-
- **Aliases.** `className` → `class`, `htmlFor` → `for`.
|
|
244
|
-
- **Boolean attributes.** `checked`, `disabled`, `required`, `readonly`, `multiple`, etc. emit as bare attributes when `true` and are dropped when `false`.
|
|
245
|
-
- **`dangerouslySetInnerHTML`.** Supported via `dangerouslySetInnerHTML={{ __html: '…' }}` — the value is inserted verbatim and is not escaped. Only use it with trusted content.
|
|
150
|
+
Every element with a directive is auto-initialized, including elements added later to the DOM.
|
|
246
151
|
|
|
247
|
-
|
|
152
|
+
---
|
|
248
153
|
|
|
249
|
-
|
|
154
|
+
## 5. Server Usage
|
|
250
155
|
|
|
251
156
|
```tsx
|
|
252
|
-
|
|
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>
|
|
299
|
-
</html>
|
|
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
|
-
* ------------------------------------------------------------------ */
|
|
157
|
+
import { Edge, jsx, Fragment } from "./dist/server.js";
|
|
325
158
|
|
|
326
159
|
const app = new Edge();
|
|
327
160
|
|
|
328
|
-
|
|
329
|
-
app.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
161
|
+
app.cors = ["abc.com", "*.cde.com"]; // ["*"] by default
|
|
162
|
+
app.headers = { "x-powered-by": "edge" }; // extra response headers
|
|
163
|
+
|
|
164
|
+
const config = {
|
|
165
|
+
valid: false, // require cookie _csrf == X-CSRF-Token
|
|
166
|
+
auth: false, // require cookie _auth
|
|
333
167
|
};
|
|
334
168
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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>
|
|
169
|
+
app.get("/", config, (c) => {
|
|
170
|
+
return c.page(
|
|
171
|
+
<html>
|
|
172
|
+
<body><h1>Hello Edge</h1></body>
|
|
173
|
+
</html>
|
|
390
174
|
);
|
|
391
175
|
});
|
|
392
176
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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>
|
|
177
|
+
app.get("/:lang/:page", config, (c) => {
|
|
178
|
+
return c.html(
|
|
179
|
+
<h1>{c.req.param("lang")} / {c.req.param("page")}</h1>
|
|
431
180
|
);
|
|
432
181
|
});
|
|
433
182
|
|
|
434
|
-
|
|
435
|
-
|
|
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);
|
|
183
|
+
app.post("/api/save", async (c) => {
|
|
184
|
+
const body = await c.req.json();
|
|
185
|
+
return c.json({ ok: true, body });
|
|
441
186
|
});
|
|
442
187
|
|
|
443
|
-
app.
|
|
444
|
-
const body = await ctx.req.json().catch(() => null);
|
|
445
|
-
return ctx.json({ updated: ctx.params.id, body });
|
|
446
|
-
});
|
|
188
|
+
app.get("/old", (c) => c.redirect("/new", 301));
|
|
447
189
|
|
|
448
|
-
|
|
449
|
-
ctx.
|
|
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',
|
|
190
|
+
export default {
|
|
191
|
+
fetch: (request, env, ctx) => app.fetch(request, env, ctx),
|
|
520
192
|
};
|
|
193
|
+
```
|
|
521
194
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
);
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
});
|
|
195
|
+
**Response helpers:**
|
|
196
|
+
|
|
197
|
+
| Helper | Behavior |
|
|
198
|
+
|--------|----------|
|
|
199
|
+
| `c.text(str, status?)` | `text/plain; charset=utf-8` |
|
|
200
|
+
| `c.json(obj \| str, status?)` | `application/json; charset=utf-8` |
|
|
201
|
+
| `c.html(str, status?)` | `text/html; charset=utf-8` |
|
|
202
|
+
| `c.page(str, status?)` | `text/html`, prepends `<!DOCTYPE html>` if needed |
|
|
203
|
+
| `c.redirect(url, 301 \| 302)` | default `302` |
|
|
204
|
+
|
|
205
|
+
**Request helpers on `c.req`:**
|
|
206
|
+
|
|
207
|
+
| Helper | Description |
|
|
208
|
+
|--------|-------------|
|
|
209
|
+
| `.param(name)` | path params |
|
|
210
|
+
| `.query(name)` | URL query |
|
|
211
|
+
| `.header(name)` | request header (lowercase) |
|
|
212
|
+
| `.cookie(name)` | parsed cookie value |
|
|
213
|
+
| `.json()` | parse JSON body |
|
|
214
|
+
| `.text()` | parse text body |
|
|
215
|
+
| `.formData()` | parse form body |
|
|
216
|
+
| `.raw` | original `Request` |
|
|
217
|
+
| `.url` | full URL string |
|
|
218
|
+
| `.method` | HTTP verb |
|
|
219
|
+
|
|
220
|
+
`c.env` is your Worker bindings. `c.ctx` is the Worker `ExecutionContext`.
|
|
605
221
|
|
|
606
|
-
|
|
607
|
-
* Scheduled handler *
|
|
608
|
-
* ================================================================== */
|
|
222
|
+
---
|
|
609
223
|
|
|
610
|
-
|
|
611
|
-
console.log('cron fired at', new Date(event.scheduledTime).toISOString());
|
|
612
|
-
// e.g. warm a cache, prune KV entries, etc.
|
|
613
|
-
});
|
|
224
|
+
## 6. Full Example (sample.tsx)
|
|
614
225
|
|
|
615
|
-
|
|
616
|
-
* Cloudflare Workers entry points *
|
|
617
|
-
* ================================================================== */
|
|
226
|
+
`sample.tsx` demonstrates:
|
|
618
227
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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)
|
|
626
242
|
|
|
627
|
-
|
|
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)
|
|
243
|
+
Run it locally:
|
|
657
244
|
|
|
658
|
-
```
|
|
659
|
-
|
|
245
|
+
```bash
|
|
246
|
+
npm run build
|
|
247
|
+
npx wrangler dev sample.tsx
|
|
660
248
|
```
|
|
661
249
|
|
|
662
|
-
|
|
250
|
+
---
|
|
663
251
|
|
|
664
|
-
|
|
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
|
-
| `_trigger` | `click` (default), `load`, `visible`, or any DOM event name. |
|
|
675
|
-
| `_form` | CSS selector or element ID of a form to serialize as the body. |
|
|
676
|
-
| `_json` | Comma-separated field names to send as a JSON body. |
|
|
677
|
-
| `_loader` | `spinner` (default), `skeleton`, or `none` / `off` / `false` to disable. |
|
|
678
|
-
| `_timeout` | Request timeout in milliseconds (default 20000). |
|
|
252
|
+
## 7. Theming (Dark / Light)
|
|
679
253
|
|
|
680
|
-
|
|
254
|
+
The page reads `data-theme` on `<html>` and drives every color through CSS custom properties. There are two token sets:
|
|
681
255
|
|
|
682
|
-
|
|
256
|
+
- `:root` → light
|
|
257
|
+
- `:root[data-theme="dark"]` → dark
|
|
683
258
|
|
|
684
|
-
|
|
259
|
+
Initialization runs synchronously in `<head>` before the first paint, so there is no flash of the wrong theme:
|
|
685
260
|
|
|
686
261
|
```html
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
262
|
+
<script>
|
|
263
|
+
(function () {
|
|
264
|
+
try {
|
|
265
|
+
var t = localStorage.getItem("theme");
|
|
266
|
+
if (t !== "dark" && t !== "light") {
|
|
267
|
+
t = window.matchMedia &&
|
|
268
|
+
window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
269
|
+
? "dark" : "light";
|
|
270
|
+
}
|
|
271
|
+
document.documentElement.setAttribute("data-theme", t);
|
|
272
|
+
} catch (e) {
|
|
273
|
+
document.documentElement.setAttribute("data-theme", "light");
|
|
274
|
+
}
|
|
275
|
+
})();
|
|
276
|
+
</script>
|
|
277
|
+
```
|
|
692
278
|
|
|
693
|
-
|
|
694
|
-
<button _post="/login" _json="username,password" _in="#status">Login</button>
|
|
279
|
+
The toggle button contains **both** the moon and the sun SVG. CSS shows only one:
|
|
695
280
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
281
|
+
```css
|
|
282
|
+
:root[data-theme="light"] .icon-btn .icon-moon { display: inline-flex }
|
|
283
|
+
:root[data-theme="dark"] .icon-btn .icon-sun { display: inline-flex }
|
|
284
|
+
```
|
|
699
285
|
|
|
700
|
-
|
|
701
|
-
<div _get="/lazy" _in="this" _trigger="visible"></div>
|
|
286
|
+
The click handler flips `data-theme`, writes to `localStorage`, and updates `aria-label`. When no preference is stored, the page follows the OS scheme.
|
|
702
287
|
|
|
703
|
-
|
|
704
|
-
<div _get="/feed" _in="this" _loader="skeleton" _timeout="5000"></div>
|
|
288
|
+
All edge-client loader/error UI uses `currentColor`, so it inherits the active theme automatically.
|
|
705
289
|
|
|
706
|
-
|
|
707
|
-
<a href="/dashboard" _go _id>Dashboard</a>
|
|
290
|
+
---
|
|
708
291
|
|
|
709
|
-
|
|
710
|
-
<a href="/docs" _open>Docs</a>
|
|
292
|
+
## 8. Icons (Inline Lucide SVG)
|
|
711
293
|
|
|
712
|
-
|
|
713
|
-
<button _post="/like" _id _in="#card-3">Like</button>
|
|
714
|
-
```
|
|
294
|
+
Every icon is an inline `<svg>` — no CDN, no `<img>`, no external requests.
|
|
715
295
|
|
|
716
|
-
|
|
296
|
+
| Icon | Purpose |
|
|
297
|
+
|------|---------|
|
|
298
|
+
| `IconMoon` | theme (dark side) |
|
|
299
|
+
| `IconSun` | theme (light side) |
|
|
300
|
+
| `IconLanguages` | brand mark |
|
|
301
|
+
| `IconRefresh` | refresh stats |
|
|
302
|
+
| `IconZap` | device ping |
|
|
303
|
+
| `IconExternal` | external link |
|
|
304
|
+
| `IconHome` | go home |
|
|
305
|
+
| `IconGlobe` | card header |
|
|
717
306
|
|
|
718
|
-
|
|
719
|
-
- `load` / `visible` — the element is observed with `IntersectionObserver` (300px root margin) and the request fires the first time it enters the viewport.
|
|
720
|
-
- Any other value — treated as a DOM event name. The listener is attached the first time the element becomes visible, then fires normally.
|
|
307
|
+
All icons use `stroke="currentColor"` and inherit their color from the surrounding element, so they follow the theme automatically.
|
|
721
308
|
|
|
722
|
-
|
|
309
|
+
---
|
|
723
310
|
|
|
724
|
-
|
|
725
|
-
- When the triggering element carries `_id`, the request includes an `X-DeviceId` header (see [Device Fingerprint](#device-fingerprint)).
|
|
726
|
-
- For `_post`, the body is JSON (`_json`), a `FormData` object (`_form`), or empty.
|
|
727
|
-
- The response text is parsed into a `<template>`.
|
|
728
|
-
- 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.
|
|
729
|
-
- Newly inserted `[_get]` / `[_post]` elements are scanned and bound.
|
|
730
|
-
- Placement depends on the target mode:
|
|
731
|
-
- `_in` — the target's existing children are removed, then the fragment is appended.
|
|
732
|
-
- `_out` — the target itself is replaced.
|
|
733
|
-
- `_before` / `_after` — the fragment is inserted adjacent to the target.
|
|
311
|
+
## 9. Build
|
|
734
312
|
|
|
735
|
-
|
|
313
|
+
```bash
|
|
314
|
+
npm run build
|
|
315
|
+
```
|
|
736
316
|
|
|
737
|
-
|
|
317
|
+
`build.mjs`:
|
|
738
318
|
|
|
739
|
-
-
|
|
740
|
-
-
|
|
741
|
-
- `
|
|
742
|
-
-
|
|
743
|
-
- 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.
|
|
744
|
-
- Loaders and error states use `role="status"` / `role="alert"` with `aria-busy` set on the target while in flight.
|
|
319
|
+
- **esbuild** — bundles and minifies `client.js` and `server.js` (target `es2022`, format `esm`). License banner injected via esbuild's `banner.js` option.
|
|
320
|
+
- **terser** — second pass, toplevel mangle + 2 compress passes.
|
|
321
|
+
- Copies `client.d.ts`, `server.d.ts`, `LICENSE` into `dist/`.
|
|
322
|
+
- Gracefully falls back to an inline MIT stub if `LICENSE` is missing.
|
|
745
323
|
|
|
746
|
-
|
|
324
|
+
**Output:**
|
|
747
325
|
|
|
748
|
-
|
|
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
|
+
```
|
|
749
333
|
|
|
750
|
-
|
|
334
|
+
---
|
|
751
335
|
|
|
752
|
-
|
|
336
|
+
## 10. Deploy (Wrangler)
|
|
753
337
|
|
|
754
|
-
|
|
338
|
+
`wrangler.toml`:
|
|
755
339
|
|
|
756
|
-
|
|
340
|
+
```toml
|
|
341
|
+
name = "edge-translations"
|
|
342
|
+
main = "sample.tsx"
|
|
343
|
+
compatibility_date = "2026-01-01"
|
|
757
344
|
|
|
758
|
-
|
|
345
|
+
[[rules]]
|
|
346
|
+
type = "Text"
|
|
347
|
+
globs = ["dist/client.js"]
|
|
348
|
+
```
|
|
759
349
|
|
|
760
|
-
|
|
761
|
-
- `_go` / `_open` → appended to the URL as `?_did=<hash>` (headers cannot be set on `location.assign` / `window.open`).
|
|
350
|
+
In `sample.tsx`, import the built `client.js` as text and expose it via `env`:
|
|
762
351
|
|
|
763
|
-
|
|
352
|
+
```tsx
|
|
353
|
+
// @ts-ignore
|
|
354
|
+
import clientJs from "./dist/client.js";
|
|
764
355
|
|
|
765
|
-
|
|
356
|
+
export default {
|
|
357
|
+
fetch: (request, env, ctx) => {
|
|
358
|
+
env.CLIENT_JS ??= clientJs;
|
|
359
|
+
return app.fetch(request, env, ctx);
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
```
|
|
766
363
|
|
|
767
|
-
|
|
364
|
+
Alternatively, serve `dist/client.js` as a static asset and drop the `/client.js` route from the app.
|
|
768
365
|
|
|
769
|
-
|
|
366
|
+
**Commands:**
|
|
770
367
|
|
|
771
|
-
```
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
const body = await ctx.req.json().catch(() => ({}));
|
|
775
|
-
return body.csrf_token && body.csrf_token === ctx.getCookie('csrf_token');
|
|
776
|
-
}
|
|
777
|
-
}, handler);
|
|
368
|
+
```bash
|
|
369
|
+
npx wrangler dev sample.tsx # local dev
|
|
370
|
+
npx wrangler deploy sample.tsx --minify # production
|
|
778
371
|
```
|
|
779
372
|
|
|
780
|
-
|
|
373
|
+
---
|
|
781
374
|
|
|
782
|
-
## Security
|
|
375
|
+
## 11. Security
|
|
783
376
|
|
|
784
|
-
|
|
785
|
-
|
|
377
|
+
**CSRF**
|
|
378
|
+
The client always sends `X-CSRF-Token` read from `<meta name="_csrf" content="...">`. Enable per-route verification with `{ valid: true }`. The server then requires cookie `_csrf == X-CSRF-Token`.
|
|
786
379
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
app.security.logSecurityEvents = true; // structured JSON events (default on)
|
|
790
|
-
app.security.extraHeaders = {
|
|
791
|
-
'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
|
|
792
|
-
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
793
|
-
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
794
|
-
};
|
|
795
|
-
```
|
|
380
|
+
**Auth**
|
|
381
|
+
Enable per-route verification with `{ auth: true }`. The server requires a cookie named `_auth`.
|
|
796
382
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
| Category | Mitigation |
|
|
800
|
-
|---|---|
|
|
801
|
-
| A01 Broken Access Control | Strict CORS allowlist, strict target resolution, no implicit trust |
|
|
802
|
-
| A02 Security Misconfiguration | Safe default headers, strict CORS allowlist, `Vary: Origin` |
|
|
803
|
-
| A03 Supply Chain | Zero deps |
|
|
804
|
-
| A04 Crypto Failures | Standard Web Crypto only; no home-grown crypto |
|
|
805
|
-
| A05 Injection / XSS | Prototype-safe objects, JSX attribute sanitization |
|
|
806
|
-
| A06 Insecure Design | Fail-closed validation, explicit response modes |
|
|
807
|
-
| A07 Auth Failures | Validation errors are surfaced, not swallowed |
|
|
808
|
-
| A08 Data Integrity | Prototype-safe JSON, `Set-Cookie` stripped from cache |
|
|
809
|
-
| A09 Logging | Structured JSON security events with 60s dedupe |
|
|
810
|
-
| A10 Exceptional Conditions | Fail-closed middleware, no internal leakage |
|
|
811
|
-
|
|
812
|
-
## Scheduled Tasks
|
|
813
|
-
|
|
814
|
-
```ts
|
|
815
|
-
app.scheduled(async (event, env, ctx) => {
|
|
816
|
-
console.log('Cron executed:', event.cron);
|
|
817
|
-
});
|
|
383
|
+
**Device identity**
|
|
384
|
+
Elements with `_id="1"` add an `X-DeviceId` header. The default `deviceId()` implementation returns `new Date().toString()`; override it globally:
|
|
818
385
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
scheduled: (event, env, ctx) => app.scheduledHandler?.(event, env, ctx),
|
|
822
|
-
};
|
|
386
|
+
```js
|
|
387
|
+
window.deviceId = () => myStableFingerprint();
|
|
823
388
|
```
|
|
824
389
|
|
|
825
|
-
|
|
390
|
+
**CORS**
|
|
826
391
|
|
|
827
|
-
|
|
392
|
+
```js
|
|
393
|
+
app.cors = ["abc.com", "*.cde.com"]; // exact + wildcard subdomains
|
|
394
|
+
app.cors = ["*"]; // allow all (default)
|
|
395
|
+
```
|
|
828
396
|
|
|
829
|
-
|
|
830
|
-
|---|---|---|
|
|
831
|
-
| `app.defaults.cors` | `{ origin, methods }` | `origin: '*'`, `methods: 'GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD'` |
|
|
832
|
-
| `app.security.logSecurityEvents` | `boolean` | `true` |
|
|
833
|
-
| `app.security.extraHeaders` | `Record<string, string> \| null` | `null` |
|
|
834
|
-
| `app.security.trustedProxies` | `string[] \| null` | `null` |
|
|
397
|
+
CORS is emitted automatically, including `OPTIONS` preflight.
|
|
835
398
|
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
app.security.extraHeaders = {
|
|
839
|
-
'Strict-Transport-Security': 'max-age=63072000; includeSubDomains',
|
|
840
|
-
};
|
|
841
|
-
```
|
|
399
|
+
**Cookies**
|
|
400
|
+
Language cookie: `lang=xx; Max-Age=31536000; Path=/; SameSite=Lax`. `HttpOnly` is deliberately **not** set so client JS can read it if needed. Add `Secure` in production by prepending `__Host-` or via response header transformation if required.
|
|
842
401
|
|
|
843
|
-
|
|
402
|
+
**Headers**
|
|
403
|
+
Every response from `Edge` goes through `withHeaders()`, which merges `app.headers` and per-route CORS headers safely.
|
|
844
404
|
|
|
845
|
-
|
|
405
|
+
**No dependencies**
|
|
406
|
+
Nothing is pulled from npm at runtime. Only build-time tooling (esbuild, terser) is dev-only.
|
|
846
407
|
|
|
847
|
-
|
|
848
|
-
|---|---|---|
|
|
849
|
-
| @lengkapp/edge | 503 | 500 |
|
|
850
|
-
| Hono | 506 | 495 |
|
|
851
|
-
| Hono (tiny) | 505 | 498 |
|
|
852
|
-
| Native Workers | 498 | 500 |
|
|
408
|
+
---
|
|
853
409
|
|
|
854
|
-
|
|
410
|
+
## 12. License
|
|
855
411
|
|
|
856
|
-
|
|
412
|
+
LengkApp Edge License
|
|
857
413
|
|
|
858
|
-
|
|
414
|
+
Copyright (c) LengkApp — Yasir Haris
|
|
415
|
+
Contact: yh@lengk.app / yasir.haris@gmail.com
|
|
859
416
|
|
|
860
|
-
|
|
417
|
+
Permission is granted to any person obtaining a copy of this software
|
|
418
|
+
and associated documentation files (the "Software") to use, copy, and
|
|
419
|
+
distribute the Software free of charge, for any purpose, including
|
|
420
|
+
commercial use, subject to the following conditions:
|
|
861
421
|
|
|
862
|
-
|
|
422
|
+
1. The Software may not be modified, adapted, or altered in any way
|
|
423
|
+
without prior written permission from the copyright holder.
|
|
863
424
|
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
| HTML insertion (client) | Response text parsed into a `<template>`; placement driven by `_in` / `_out` / `_before` / `_after` |
|
|
867
|
-
| Script execution (client) | Extracted `<script>` elements re-created and appended to `<head>`; external scripts de-duplicated by absolute URL |
|
|
868
|
-
| Request hygiene (client) | `credentials: 'same-origin'`, `X-Requested-With: XMLHttpRequest`, optional `X-DeviceId` (only when `_id` is present), abortable via `AbortController`, `_timeout` (default 20s) |
|
|
869
|
-
| Cookies | `credentials: 'same-origin'`; names validated on the server |
|
|
870
|
-
| Server response headers | `nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy` |
|
|
871
|
-
| Server middleware | Fail-closed validation; structured security logs; strict CORS allowlist |
|
|
872
|
-
| Prototype pollution | `Object.create(null)` + forbidden-key filtering in params, cookies, JSON, JSX (server) |
|
|
873
|
-
| Build | Reserved exports/properties, `keep_quoted: "strict"`, per-bundle post-minification self-test |
|
|
425
|
+
2. Redistribution of the Software, in whole or in part, must retain
|
|
426
|
+
this LICENSE file unmodified and include the copyright notice above.
|
|
874
427
|
|
|
875
|
-
|
|
428
|
+
3. This permission notice does not grant any right to use the
|
|
429
|
+
LengkApp name, brand, or trademarks without separate written
|
|
430
|
+
permission.
|
|
876
431
|
|
|
877
|
-
|
|
878
|
-
|
|
432
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
433
|
+
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
434
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
435
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHOR OR COPYRIGHT HOLDER BE
|
|
436
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
437
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
438
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|