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