@lengkapp/edge 0.0.42 → 0.0.44
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 -680
- package/dist/README.md +618 -680
- 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,882 +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
|
-
- **Scalable Translation** – `_translate={indentifier}`, based on `html lang={lang-id}` it will look for `/t/{lang-id}/{identifier}.jon`
|
|
51
|
-
- **Built-in loading and error states** – deferred spinner, skeleton loader, abortable requests, one-click retry
|
|
52
|
-
- **View Transitions aware** – swaps run inside `document.startViewTransition` when available
|
|
53
|
-
- **Zero dependencies**
|
|
46
|
+
The package is ESM-only (`"type": "module"`).
|
|
54
47
|
|
|
55
|
-
|
|
48
|
+
## Project Setup
|
|
56
49
|
|
|
57
|
-
|
|
58
|
-
- **XSS-hardened JSX** – no `on*` attributes, no `javascript:` URLs, no malformed tag names
|
|
59
|
-
- **Structured security logging** – throttled JSON events for validation and handler failures
|
|
60
|
-
- **Fail-closed middleware** – validation and handler errors deny by default
|
|
61
|
-
- **Strict CORS allowlist** – per-origin reflection with `Vary: Origin`
|
|
50
|
+
**`wrangler.toml`**
|
|
62
51
|
|
|
63
|
-
|
|
52
|
+
```toml
|
|
53
|
+
name = 'my-edge-app'
|
|
54
|
+
main = 'index.js'
|
|
55
|
+
compatibility_date = "2026-08-31"
|
|
56
|
+
```
|
|
64
57
|
|
|
65
|
-
|
|
58
|
+
**`package.json`**
|
|
66
59
|
|
|
67
|
-
```
|
|
68
|
-
|
|
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
|
+
}
|
|
69
75
|
```
|
|
70
76
|
|
|
71
77
|
## Quick Start
|
|
72
78
|
|
|
73
|
-
|
|
79
|
+
**`index.js`**
|
|
74
80
|
|
|
75
|
-
```
|
|
76
|
-
|
|
81
|
+
```jsx
|
|
82
|
+
/** @jsx jsx */
|
|
83
|
+
/** @jsxFrag Fragment */
|
|
84
|
+
import { Edge, jsx, Fragment } from '@lengkapp/edge';
|
|
77
85
|
|
|
78
86
|
const app = new Edge();
|
|
79
87
|
|
|
80
|
-
app.get('/', (
|
|
81
|
-
|
|
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
|
+
);
|
|
82
101
|
|
|
83
102
|
export default app;
|
|
84
103
|
```
|
|
85
104
|
|
|
86
|
-
|
|
105
|
+
```bash
|
|
106
|
+
npm run dev # http://localhost:8787
|
|
107
|
+
npm run deploy
|
|
108
|
+
```
|
|
87
109
|
|
|
88
|
-
|
|
89
|
-
|
|
110
|
+
> **Why `export default app`?**
|
|
111
|
+
> The `Edge` instance exposes a `fetch(request, env, executionCtx)` method, which is exactly the Workers module-syntax contract.
|
|
90
112
|
|
|
91
|
-
|
|
113
|
+
### JSX pragma
|
|
92
114
|
|
|
93
|
-
|
|
94
|
-
<div>card</div>
|
|
95
|
-
);
|
|
115
|
+
The framework uses the classic JSX transform. Add these two comments at the top of every file that contains JSX:
|
|
96
116
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
</>
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
app.get('/', () => <LandingPage />);
|
|
105
|
-
|
|
106
|
-
export default app;
|
|
117
|
+
```js
|
|
118
|
+
/** @jsx jsx */
|
|
119
|
+
/** @jsxFrag Fragment */
|
|
120
|
+
import { jsx, Fragment } from '@lengkapp/edge';
|
|
107
121
|
```
|
|
108
122
|
|
|
109
|
-
|
|
123
|
+
That's all the configuration required — no Babel, no tsconfig, no plugin.
|
|
110
124
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
"moduleResolution": "Bundler",
|
|
122
|
-
"strict": true,
|
|
123
|
-
"skipLibCheck": true,
|
|
124
|
-
"lib": ["ESNext", "WebWorker"]
|
|
125
|
-
}
|
|
126
|
-
}
|
|
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);
|
|
127
135
|
```
|
|
128
136
|
|
|
129
|
-
`
|
|
137
|
+
`options` may be omitted entirely, or passed as the second argument:
|
|
130
138
|
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
"name": "my-edge-app",
|
|
135
|
-
"main": "worker.js",
|
|
136
|
-
"compatibility_date": "2026-09-06"
|
|
137
|
-
}
|
|
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 }));
|
|
138
142
|
```
|
|
139
143
|
|
|
140
|
-
|
|
144
|
+
### Static vs. dynamic paths
|
|
141
145
|
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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);
|
|
146
150
|
```
|
|
147
151
|
|
|
148
|
-
|
|
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).
|
|
149
155
|
|
|
150
|
-
```
|
|
151
|
-
|
|
156
|
+
```js
|
|
157
|
+
app.get('/users/:id', (c) => {
|
|
158
|
+
return c.json({ id: c.params.id }); // GET /users/42 → {"id":"42"}
|
|
159
|
+
});
|
|
152
160
|
```
|
|
153
161
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
### Context
|
|
162
|
+
Unmatched requests receive `404 Not Found` as `text/plain`.
|
|
157
163
|
|
|
158
|
-
|
|
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 |
|
|
164
|
+
## Context API
|
|
175
165
|
|
|
176
|
-
Every
|
|
166
|
+
Every handler receives a `Context` instance.
|
|
177
167
|
|
|
178
|
-
###
|
|
168
|
+
### Properties
|
|
179
169
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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);
|
|
186
190
|
```
|
|
187
191
|
|
|
188
|
-
|
|
192
|
+
`html()` and `page()` accept:
|
|
189
193
|
|
|
190
|
-
|
|
194
|
+
- a string (passed through)
|
|
195
|
+
- a JSX node or array of nodes (rendered with `renderToString`)
|
|
196
|
+
- anything else (`String(data)`)
|
|
191
197
|
|
|
192
|
-
|
|
193
|
-
app.defaults.cors.origin = ['https://app.example.com'];
|
|
194
|
-
```
|
|
198
|
+
Both are async because component trees may contain async components.
|
|
195
199
|
|
|
196
|
-
|
|
200
|
+
### Headers
|
|
197
201
|
|
|
198
|
-
|
|
202
|
+
`c.headers` is a `Headers` instance already containing the base security headers. Mutate it directly before returning a response:
|
|
199
203
|
|
|
200
|
-
|
|
204
|
+
```js
|
|
205
|
+
app.get('/custom', (c) => {
|
|
206
|
+
c.headers.set('X-Custom', '1');
|
|
207
|
+
return c.text('ok');
|
|
208
|
+
});
|
|
209
|
+
```
|
|
201
210
|
|
|
202
|
-
|
|
211
|
+
Or pass per-response overrides as the third argument:
|
|
203
212
|
|
|
204
|
-
|
|
213
|
+
```js
|
|
214
|
+
c.json(data, 200, { 'Cache-Control': 'no-store' });
|
|
215
|
+
```
|
|
205
216
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
+
});
|
|
210
233
|
|
|
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" />]));
|
|
234
|
+
// Delete
|
|
235
|
+
c.deleteCookie('session', { path: '/' });
|
|
215
236
|
|
|
216
|
-
|
|
217
|
-
app.get('/raw', (ctx) => ctx.html('<p>pre-rendered</p>'));
|
|
237
|
+
return c.text('done');
|
|
218
238
|
```
|
|
219
239
|
|
|
220
|
-
|
|
240
|
+
Cookie names are validated against `__proto__`, `constructor`, `prototype`, and CRLF / `;` / `=` injection.
|
|
221
241
|
|
|
222
|
-
|
|
223
|
-
app.get('/', () => <LandingPage />);
|
|
224
|
-
```
|
|
242
|
+
## JSX & Server-Side Rendering
|
|
225
243
|
|
|
226
|
-
`renderToString
|
|
244
|
+
### `renderToString(node, ctx?)`
|
|
227
245
|
|
|
228
|
-
```
|
|
246
|
+
```js
|
|
229
247
|
import { renderToString } from '@lengkapp/edge';
|
|
230
248
|
|
|
231
|
-
const html = renderToString(<
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
**`renderToString` hardening:**
|
|
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.
|
|
246
|
-
|
|
247
|
-
## Full Example
|
|
248
|
-
|
|
249
|
-
A single file that exercises every server feature.
|
|
250
|
-
|
|
251
|
-
```tsx
|
|
252
|
-
// sample.tsx
|
|
253
|
-
//
|
|
254
|
-
// Demonstrates every feature of @lengkapp/edge:
|
|
255
|
-
// - static & dynamic routes, all HTTP methods
|
|
256
|
-
// - params, query, cookies (get/set/delete)
|
|
257
|
-
// - ctx.text / ctx.json / ctx.html / ctx.redirect
|
|
258
|
-
// - JSX rendering (elements, Fragments, function components, arrays)
|
|
259
|
-
// - style objects, boolean attributes, void elements,
|
|
260
|
-
// className/htmlFor aliases, dangerouslySetInnerHTML
|
|
261
|
-
// - route options: cors, cache, compress, log, validate
|
|
262
|
-
// - security.extraHeaders, security.logSecurityEvents
|
|
263
|
-
// - scheduled handler
|
|
264
|
-
// - returning JSX directly from a handler
|
|
265
|
-
|
|
266
|
-
import {
|
|
267
|
-
Edge,
|
|
268
|
-
Context,
|
|
269
|
-
Fragment,
|
|
270
|
-
renderToString,
|
|
271
|
-
type JSXNode,
|
|
272
|
-
type RouteOptions,
|
|
273
|
-
} from '@lengkapp/edge';
|
|
249
|
+
const html = await renderToString(<div>Hello</div>);
|
|
250
|
+
```
|
|
274
251
|
|
|
275
|
-
|
|
276
|
-
* Small helper components (JSX function components) *
|
|
277
|
-
* ------------------------------------------------------------------ */
|
|
252
|
+
Accepts `null`, `undefined`, booleans (rendered as `''`), strings, numbers, promises/thenables, arrays, and JSX nodes. All text and attribute values are HTML-escaped.
|
|
278
253
|
|
|
279
|
-
|
|
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
|
-
}
|
|
254
|
+
### Components
|
|
302
255
|
|
|
303
|
-
|
|
256
|
+
```jsx
|
|
257
|
+
function Card({ title, children }) {
|
|
304
258
|
return (
|
|
305
|
-
<div
|
|
306
|
-
<h2>{
|
|
307
|
-
{
|
|
259
|
+
<div className="card">
|
|
260
|
+
<h2>{title}</h2>
|
|
261
|
+
{children}
|
|
308
262
|
</div>
|
|
309
263
|
);
|
|
310
264
|
}
|
|
311
265
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
<
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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>;
|
|
320
289
|
}
|
|
321
290
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
291
|
+
app.get('/', (c) => c.page(
|
|
292
|
+
<div>It is <Weather city="Jakarta" /></div>
|
|
293
|
+
));
|
|
294
|
+
```
|
|
325
295
|
|
|
326
|
-
|
|
296
|
+
`renderToString` awaits every node recursively.
|
|
327
297
|
|
|
328
|
-
|
|
329
|
-
app.security.logSecurityEvents = true;
|
|
330
|
-
app.security.extraHeaders = {
|
|
331
|
-
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
|
|
332
|
-
'X-Custom-Powered-By': 'edge-server',
|
|
333
|
-
};
|
|
298
|
+
### `useCtx()`
|
|
334
299
|
|
|
335
|
-
|
|
336
|
-
* Basic routes *
|
|
337
|
-
* ================================================================== */
|
|
300
|
+
Call synchronously at the top of a component (before any `await`) to read the active `Context`:
|
|
338
301
|
|
|
339
|
-
|
|
340
|
-
|
|
302
|
+
```jsx
|
|
303
|
+
import { useCtx } from '@lengkapp/edge';
|
|
341
304
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
305
|
+
function UserBadge() {
|
|
306
|
+
const c = useCtx();
|
|
307
|
+
return <span>Route param: {c.params.id}</span>;
|
|
308
|
+
}
|
|
309
|
+
```
|
|
346
310
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
));
|
|
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
|
+
```
|
|
356
319
|
|
|
357
|
-
|
|
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
|
-
);
|
|
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.
|
|
373
321
|
|
|
374
|
-
|
|
375
|
-
app.get('/raw', (ctx) =>
|
|
376
|
-
ctx.html('<h1>Raw HTML</h1><p>Not escaped.</p>')
|
|
377
|
-
);
|
|
322
|
+
> **Note:** Do not call `useCtx()` from async continuations; capture `c` into a local variable instead.
|
|
378
323
|
|
|
379
|
-
|
|
380
|
-
* Params, query, cookies *
|
|
381
|
-
* ================================================================== */
|
|
324
|
+
### Prop → attribute mapping
|
|
382
325
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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 |
|
|
392
336
|
|
|
393
|
-
|
|
394
|
-
app.get('/posts/:year/:slug', (ctx) => {
|
|
395
|
-
const { year, slug } = ctx.params;
|
|
396
|
-
return ctx.json({ year, slug });
|
|
397
|
-
});
|
|
337
|
+
Numeric style values automatically get `px` appended unless the property is unitless (`opacity`, `z-index`, `line-height`, `flex`, …).
|
|
398
338
|
|
|
399
|
-
|
|
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
|
-
});
|
|
339
|
+
### Raw HTML
|
|
405
340
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
path: '/',
|
|
410
|
-
httpOnly: true,
|
|
411
|
-
secure: true,
|
|
412
|
-
sameSite: 'Lax',
|
|
413
|
-
maxAge: 3600,
|
|
414
|
-
});
|
|
415
|
-
return ctx.redirect('/dashboard');
|
|
416
|
-
});
|
|
341
|
+
```jsx
|
|
342
|
+
<div dangerouslySetInnerHTML={{ __html: trustedHtml }} />
|
|
343
|
+
```
|
|
417
344
|
|
|
418
|
-
|
|
419
|
-
ctx.deleteCookie('session', { path: '/' });
|
|
420
|
-
return ctx.redirect('/');
|
|
421
|
-
});
|
|
345
|
+
This bypasses escaping. **Only use it with content you fully control.**
|
|
422
346
|
|
|
423
|
-
|
|
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>
|
|
431
|
-
);
|
|
432
|
-
});
|
|
347
|
+
### Sanitisation
|
|
433
348
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
+
```
|
|
437
366
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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);
|
|
441
386
|
});
|
|
387
|
+
```
|
|
442
388
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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);
|
|
446
397
|
});
|
|
398
|
+
```
|
|
447
399
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
)
|
|
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).
|
|
451
407
|
|
|
452
|
-
|
|
453
|
-
ctx.json({ deleted: ctx.params.id }, 200)
|
|
454
|
-
);
|
|
408
|
+
## Compression
|
|
455
409
|
|
|
456
|
-
|
|
410
|
+
```js
|
|
411
|
+
app.get('/big', { compress: true }, (c) => c.html(hugeMarkup));
|
|
412
|
+
```
|
|
457
413
|
|
|
458
|
-
|
|
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`.
|
|
459
418
|
|
|
460
|
-
|
|
461
|
-
* Route options: cors, cache, compress, log, validate *
|
|
462
|
-
* ================================================================== */
|
|
419
|
+
## CORS
|
|
463
420
|
|
|
464
|
-
|
|
465
|
-
app.get(
|
|
466
|
-
'/cors-open',
|
|
467
|
-
{ cors: true, log: true },
|
|
468
|
-
(ctx) => ctx.json({ cors: 'wildcard' })
|
|
469
|
-
);
|
|
421
|
+
Global default (used when `cors: true`):
|
|
470
422
|
|
|
471
|
-
|
|
472
|
-
app.
|
|
473
|
-
'
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
methods: 'GET, POST',
|
|
478
|
-
headers: 'Content-Type, X-CSRF-Token',
|
|
479
|
-
},
|
|
480
|
-
},
|
|
481
|
-
(ctx) => ctx.json({ cors: 'restricted' })
|
|
482
|
-
);
|
|
423
|
+
```js
|
|
424
|
+
app.defaults.cors = {
|
|
425
|
+
origin: '*',
|
|
426
|
+
methods: 'GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD',
|
|
427
|
+
};
|
|
428
|
+
```
|
|
483
429
|
|
|
484
|
-
|
|
485
|
-
app.get(
|
|
486
|
-
'/cached',
|
|
487
|
-
{
|
|
488
|
-
cache: { ttl: 60, staleWhileRevalidate: 30 },
|
|
489
|
-
log: true,
|
|
490
|
-
},
|
|
491
|
-
(ctx) => ctx.json({ generatedAt: Date.now() })
|
|
492
|
-
);
|
|
430
|
+
Per-route:
|
|
493
431
|
|
|
494
|
-
|
|
495
|
-
app.get(
|
|
496
|
-
'/big',
|
|
497
|
-
{ compress: true },
|
|
498
|
-
(ctx) => ctx.html(`<pre>${'x'.repeat(5000)}</pre>`)
|
|
499
|
-
);
|
|
432
|
+
```js
|
|
433
|
+
app.get('/api/public', { cors: true }, handler);
|
|
500
434
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const token = ctx.req.headers.get('X-Admin-Token');
|
|
507
|
-
return token === 'let-me-in';
|
|
508
|
-
},
|
|
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',
|
|
509
440
|
},
|
|
510
|
-
|
|
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',
|
|
520
|
-
};
|
|
441
|
+
}, handler);
|
|
521
442
|
|
|
522
|
-
app.get('/
|
|
523
|
-
|
|
524
|
-
<Layout title="Everything">
|
|
525
|
-
<h1>All options at once</h1>
|
|
526
|
-
</Layout>
|
|
527
|
-
)
|
|
528
|
-
);
|
|
443
|
+
app.get('/api/exact', { cors: { origin: 'https://example.com' } }, handler);
|
|
444
|
+
```
|
|
529
445
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
*
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
ctx.html(
|
|
536
|
-
<Layout title="JSX Gallery">
|
|
537
|
-
{/* Style objects → kebab-cased, numbers get px added */}
|
|
538
|
-
<div
|
|
539
|
-
style={{
|
|
540
|
-
backgroundColor: 'tomato',
|
|
541
|
-
padding: 12,
|
|
542
|
-
opacity: 0.9,
|
|
543
|
-
lineHeight: 1.4, // unitless, stays as-is
|
|
544
|
-
}}
|
|
545
|
-
>
|
|
546
|
-
Styled box
|
|
547
|
-
</div>
|
|
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` |
|
|
548
451
|
|
|
549
|
-
|
|
550
|
-
<label className="lbl" htmlFor="name">
|
|
551
|
-
Name
|
|
552
|
-
</label>
|
|
553
|
-
<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.
|
|
554
453
|
|
|
555
|
-
|
|
556
|
-
<input type="checkbox" checked readOnly />
|
|
557
|
-
<button disabled>Nope</button>
|
|
454
|
+
## Security
|
|
558
455
|
|
|
559
|
-
|
|
560
|
-
<img src="/logo.png" alt="logo" />
|
|
561
|
-
<br />
|
|
562
|
-
<hr />
|
|
456
|
+
### Base headers (always applied)
|
|
563
457
|
|
|
564
|
-
|
|
565
|
-
|
|
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
|
+
```
|
|
566
464
|
|
|
567
|
-
|
|
568
|
-
<>
|
|
569
|
-
<p>Fragment child A</p>
|
|
570
|
-
<p>Fragment child B</p>
|
|
571
|
-
</>
|
|
465
|
+
### Extra headers
|
|
572
466
|
|
|
573
|
-
|
|
574
|
-
|
|
467
|
+
```js
|
|
468
|
+
app.security.extraHeaders = {
|
|
469
|
+
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
|
|
470
|
+
'Content-Security-Policy': "default-src 'self'",
|
|
471
|
+
};
|
|
472
|
+
```
|
|
575
473
|
|
|
576
|
-
|
|
577
|
-
<p>{'<script>alert(1)</script>'}</p>
|
|
474
|
+
### Security event logging
|
|
578
475
|
|
|
579
|
-
|
|
580
|
-
<a href="javascript:alert(1)">nope</a>
|
|
581
|
-
<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.
|
|
582
477
|
|
|
583
|
-
|
|
584
|
-
|
|
478
|
+
```js
|
|
479
|
+
app.security.logSecurityEvents = false; // disable
|
|
480
|
+
```
|
|
585
481
|
|
|
586
|
-
|
|
587
|
-
<p>{null}{undefined}{false}{true}</p>
|
|
588
|
-
</Layout>
|
|
589
|
-
)
|
|
590
|
-
);
|
|
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.
|
|
591
483
|
|
|
592
|
-
|
|
593
|
-
* renderToString() standalone *
|
|
594
|
-
* ================================================================== */
|
|
484
|
+
## Scheduled Handler
|
|
595
485
|
|
|
596
|
-
app.
|
|
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
|
-
});
|
|
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:
|
|
605
487
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
* ================================================================== */
|
|
488
|
+
```js
|
|
489
|
+
const app = new Edge();
|
|
609
490
|
|
|
610
491
|
app.scheduled(async (event, env, ctx) => {
|
|
611
|
-
console.log('cron fired at',
|
|
612
|
-
// e.g. warm a cache, prune KV entries, etc.
|
|
492
|
+
console.log('cron fired at', event.scheduledTime);
|
|
613
493
|
});
|
|
614
494
|
|
|
615
|
-
/* ================================================================== *
|
|
616
|
-
* Cloudflare Workers entry points *
|
|
617
|
-
* ================================================================== */
|
|
618
|
-
|
|
619
495
|
export default {
|
|
620
|
-
fetch: (
|
|
621
|
-
|
|
622
|
-
scheduled: (event: ScheduledEvent, env: any, ctx: ExecutionContext) =>
|
|
623
|
-
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),
|
|
624
498
|
};
|
|
625
499
|
```
|
|
626
500
|
|
|
627
|
-
|
|
501
|
+
## Client-Side Partial Updates
|
|
628
502
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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)
|
|
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:
|
|
657
508
|
|
|
658
509
|
```html
|
|
659
|
-
<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>
|
|
660
517
|
```
|
|
661
518
|
|
|
662
519
|
### Attributes
|
|
663
520
|
|
|
664
|
-
| Attribute | Description |
|
|
665
|
-
|
|
666
|
-
| `_get`
|
|
667
|
-
| `
|
|
668
|
-
| `
|
|
669
|
-
| `
|
|
670
|
-
| `
|
|
671
|
-
| `
|
|
672
|
-
| `
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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.
|
|
684
542
|
|
|
685
543
|
### Examples
|
|
686
544
|
|
|
545
|
+
**Refresh a fragment**
|
|
546
|
+
|
|
687
547
|
```html
|
|
688
|
-
|
|
689
|
-
<
|
|
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
|
+
```
|
|
690
555
|
|
|
691
|
-
|
|
692
|
-
<div _get="/user-profile" _out="this"></div>
|
|
556
|
+
**Submit a form and replace a list**
|
|
693
557
|
|
|
694
|
-
|
|
695
|
-
<
|
|
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
|
+
```
|
|
696
569
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
+
```
|
|
700
578
|
|
|
701
|
-
|
|
702
|
-
<div _get="/lazy" _in="this" _trigger="visible"></div>
|
|
579
|
+
**POST JSON from a single input**
|
|
703
580
|
|
|
704
|
-
|
|
705
|
-
<
|
|
581
|
+
```html
|
|
582
|
+
<input id="q" name="q" _post="/api/search" _json="#q" _trigger="keyup" _in="#results" />
|
|
583
|
+
<div id="results"></div>
|
|
584
|
+
```
|
|
706
585
|
|
|
707
|
-
|
|
708
|
-
<div _get="/feed-error" _in="this" _toast="#toast"></div>
|
|
586
|
+
**Load on scroll into view**
|
|
709
587
|
|
|
710
|
-
|
|
711
|
-
<
|
|
588
|
+
```html
|
|
589
|
+
<div _get="/api/feed/page/2" _trigger="visible" _in="this">
|
|
590
|
+
Loading…
|
|
591
|
+
</div>
|
|
592
|
+
```
|
|
712
593
|
|
|
713
|
-
|
|
714
|
-
<a href="/docs" _open>Docs</a>
|
|
594
|
+
**Fire on page load**
|
|
715
595
|
|
|
716
|
-
|
|
717
|
-
<
|
|
596
|
+
```html
|
|
597
|
+
<div _get="/api/notifications" _trigger="load" _in="#bell"></div>
|
|
718
598
|
```
|
|
719
599
|
|
|
720
|
-
###
|
|
600
|
+
### Request headers
|
|
721
601
|
|
|
722
|
-
|
|
723
|
-
- `load` / `visible` — the element is observed with `IntersectionObserver` (300px root margin) and the request fires the first time it enters the viewport.
|
|
724
|
-
- Any other value — treated as a DOM event name. The listener is attached the first time the element becomes visible, then fires normally.
|
|
602
|
+
Every request automatically includes:
|
|
725
603
|
|
|
726
|
-
|
|
604
|
+
| Header | Source |
|
|
605
|
+
|---|---|
|
|
606
|
+
| `x-csrf-token` | `<meta name="_csrf" content="…">` |
|
|
607
|
+
| `x-auth-token` | `<meta name="_auth" content="…">` |
|
|
727
608
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
- Any `<script>` elements are lifted out, then re-created and appended to `<head>` so the browser executes them. External scripts are de-duplicated by absolute URL.
|
|
733
|
-
- Newly inserted `[_get]` / `[_post]` elements are scanned and bound.
|
|
734
|
-
- Placement depends on the target mode:
|
|
735
|
-
- `_in` — the target's existing children are removed, then the fragment is appended.
|
|
736
|
-
- `_out` — the target itself is replaced.
|
|
737
|
-
- `_before` / `_after` — the fragment is inserted adjacent to the target.
|
|
609
|
+
```html
|
|
610
|
+
<meta name="_csrf" content="<%= csrfToken %>" />
|
|
611
|
+
<meta name="_auth" content="<%= authToken %>" />
|
|
612
|
+
```
|
|
738
613
|
|
|
739
|
-
|
|
614
|
+
Missing metas simply produce empty strings.
|
|
740
615
|
|
|
741
|
-
###
|
|
616
|
+
### Body encoding
|
|
742
617
|
|
|
743
|
-
-
|
|
744
|
-
-
|
|
745
|
-
- `
|
|
746
|
-
-
|
|
747
|
-
- On failure — non-2xx, network error, or `_timeout` — the loader is replaced in place by an error box with a retry button that re-issues the request.
|
|
748
|
-
- Loaders and error states use `role="status"` / `role="alert"` with `aria-busy` set on the target while in flight.
|
|
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
|
|
749
622
|
|
|
750
|
-
###
|
|
623
|
+
### Response handling
|
|
751
624
|
|
|
752
|
-
|
|
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
|
|
753
628
|
|
|
754
|
-
|
|
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.
|
|
755
630
|
|
|
756
|
-
|
|
631
|
+
### Auto IDs
|
|
757
632
|
|
|
758
|
-
|
|
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.
|
|
759
634
|
|
|
760
|
-
|
|
635
|
+
## Complete Example
|
|
761
636
|
|
|
762
|
-
|
|
637
|
+
**`index.js`**
|
|
763
638
|
|
|
764
|
-
|
|
765
|
-
|
|
639
|
+
```jsx
|
|
640
|
+
/** @jsx jsx */
|
|
641
|
+
/** @jsxFrag Fragment */
|
|
642
|
+
import { Edge, jsx, Fragment, useCtx } from '@lengkapp/edge';
|
|
766
643
|
|
|
767
|
-
|
|
644
|
+
const app = new Edge();
|
|
768
645
|
|
|
769
|
-
|
|
646
|
+
/* ------------------------------------------------------------------ *
|
|
647
|
+
* Data *
|
|
648
|
+
* ------------------------------------------------------------------ */
|
|
649
|
+
let seq = 0;
|
|
650
|
+
const todos = new Map();
|
|
770
651
|
|
|
771
|
-
|
|
652
|
+
function addTodo(text) {
|
|
653
|
+
const id = String(++seq);
|
|
654
|
+
todos.set(id, { id, text, done: false });
|
|
655
|
+
return todos.get(id);
|
|
656
|
+
}
|
|
772
657
|
|
|
773
|
-
|
|
658
|
+
addTodo('Read the docs');
|
|
659
|
+
addTodo('Deploy to production');
|
|
774
660
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
+
}
|
|
783
685
|
|
|
784
|
-
|
|
686
|
+
function TodoItem({ todo }) {
|
|
687
|
+
return <li id={'todo-' + todo.id}>{todo.text}</li>;
|
|
688
|
+
}
|
|
785
689
|
|
|
786
|
-
|
|
690
|
+
function TodoList() {
|
|
691
|
+
return <>{[...todos.values()].map((t) => <TodoItem todo={t} />)}</>;
|
|
692
|
+
}
|
|
787
693
|
|
|
788
|
-
|
|
789
|
-
|
|
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>
|
|
790
702
|
|
|
791
|
-
|
|
703
|
+
<button _get="/api/time" _in="#clock">Refresh time</button>
|
|
704
|
+
<p id="clock">—</p>
|
|
792
705
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
```
|
|
706
|
+
<div _get="/api/greeting" _trigger="visible" _in="this">
|
|
707
|
+
Scroll-triggered content loading…
|
|
708
|
+
</div>
|
|
709
|
+
</Layout>
|
|
710
|
+
)
|
|
711
|
+
);
|
|
800
712
|
|
|
801
|
-
|
|
713
|
+
function nameSuffix(c) {
|
|
714
|
+
const n = c.query.get('name');
|
|
715
|
+
return n ? `, ${n}` : '';
|
|
716
|
+
}
|
|
802
717
|
|
|
803
|
-
|
|
804
|
-
|---|---|
|
|
805
|
-
| A01 Broken Access Control | Strict CORS allowlist, strict target resolution, no implicit trust |
|
|
806
|
-
| A02 Security Misconfiguration | Safe default headers, strict CORS allowlist, `Vary: Origin` |
|
|
807
|
-
| A03 Supply Chain | Zero deps |
|
|
808
|
-
| A04 Crypto Failures | Standard Web Crypto only; no home-grown crypto |
|
|
809
|
-
| A05 Injection / XSS | Prototype-safe objects, JSX attribute sanitization |
|
|
810
|
-
| A06 Insecure Design | Fail-closed validation, explicit response modes |
|
|
811
|
-
| A07 Auth Failures | Validation errors are surfaced, not swallowed |
|
|
812
|
-
| A08 Data Integrity | Prototype-safe JSON, `Set-Cookie` stripped from cache |
|
|
813
|
-
| A09 Logging | Structured JSON security events with 60s dedupe |
|
|
814
|
-
| A10 Exceptional Conditions | Fail-closed middleware, no internal leakage |
|
|
815
|
-
|
|
816
|
-
## Scheduled Tasks
|
|
817
|
-
|
|
818
|
-
```ts
|
|
819
|
-
app.scheduled(async (event, env, ctx) => {
|
|
820
|
-
console.log('Cron executed:', event.cron);
|
|
821
|
-
});
|
|
718
|
+
app.get('/api/time', (c) => c.html(<strong>{new Date().toISOString()}</strong>));
|
|
822
719
|
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
};
|
|
827
|
-
```
|
|
720
|
+
app.get('/api/greeting', (c) =>
|
|
721
|
+
c.html(<p>👋 Loaded lazily when it became visible.</p>)
|
|
722
|
+
);
|
|
828
723
|
|
|
829
|
-
|
|
724
|
+
app.get('/todos', (c) =>
|
|
725
|
+
c.page(
|
|
726
|
+
<Layout title="Todos">
|
|
727
|
+
<h1>Todos</h1>
|
|
830
728
|
|
|
831
|
-
|
|
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>
|
|
832
734
|
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
735
|
+
<ul id="list">
|
|
736
|
+
<TodoList />
|
|
737
|
+
</ul>
|
|
738
|
+
</Layout>
|
|
739
|
+
)
|
|
740
|
+
);
|
|
839
741
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
+
});
|
|
846
772
|
|
|
847
|
-
|
|
773
|
+
app.get('/api/theme', (c) => c.json({ theme: c.getCookie('theme') || 'light' }));
|
|
848
774
|
|
|
849
|
-
|
|
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
|
+
);
|
|
850
785
|
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
+
```
|
|
857
795
|
|
|
858
|
-
|
|
796
|
+
## License
|
|
859
797
|
|
|
860
|
-
|
|
798
|
+
```text
|
|
799
|
+
LengkApp Edge License
|
|
861
800
|
|
|
862
|
-
|
|
801
|
+
Copyright (c) LengkApp — Yasir Haris
|
|
802
|
+
Contact: yh@lengk.app / yasir.haris@gmail.com
|
|
863
803
|
|
|
864
|
-
|
|
804
|
+
Permission is granted to use, copy, and distribute this software free of charge,
|
|
805
|
+
including for commercial purposes, provided that:
|
|
865
806
|
|
|
866
|
-
|
|
807
|
+
1. The Software may not be modified, adapted, or altered in any way without
|
|
808
|
+
prior written permission from the copyright holder.
|
|
867
809
|
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
| HTML insertion (client) | Response text parsed into a `<template>`; placement driven by `_in` / `_out` / `_before` / `_after` |
|
|
871
|
-
| Script execution (client) | Extracted `<script>` elements re-created and appended to `<head>`; external scripts de-duplicated by absolute URL |
|
|
872
|
-
| Request hygiene (client) | `credentials: 'same-origin'`, `X-Requested-With: XMLHttpRequest`, optional `X-DeviceId` (only when `_id` is present), abortable via `AbortController`, `_timeout` (default 20s) |
|
|
873
|
-
| Cookies | `credentials: 'same-origin'`; names validated on the server |
|
|
874
|
-
| Server response headers | `nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy` |
|
|
875
|
-
| Server middleware | Fail-closed validation; structured security logs; strict CORS allowlist |
|
|
876
|
-
| Prototype pollution | `Object.create(null)` + forbidden-key filtering in params, cookies, JSON, JSX (server) |
|
|
877
|
-
| Build | Reserved exports/properties, `keep_quoted: "strict"`, per-bundle post-minification self-test |
|
|
810
|
+
2. Redistribution, in whole or in part, must retain the unmodified LICENSE file
|
|
811
|
+
and the copyright notice above.
|
|
878
812
|
|
|
879
|
-
|
|
813
|
+
3. No right is granted to use the LengkApp name, brand, or trademarks without
|
|
814
|
+
separate written permission.
|
|
880
815
|
|
|
881
|
-
|
|
882
|
-
|
|
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
|
+
```
|