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