@lengkapp/edge 0.0.40 → 0.0.42
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 +171 -202
- package/dist/README.md +171 -202
- package/dist/edge-client.d.ts +1 -0
- package/dist/edge-client.js +33 -0
- package/dist/{server.d.ts → edge-server.d.ts} +33 -1
- package/dist/edge-server.js +33 -0
- package/package.json +12 -14
- package/dist/client.d.ts +0 -109
- package/dist/client.js +0 -29
- package/dist/server.js +0 -29
package/README.md
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
A minimal, high-performance framework for Cloudflare Workers with built-in server-side rendering, routing, caching, and a declarative client-side partial-update library.
|
|
4
4
|
|
|
5
|
-
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
|
+
---
|
|
8
10
|
|
|
9
11
|
## Table of Contents
|
|
10
12
|
|
|
@@ -15,21 +17,23 @@ Inspired by [Hono](https://hono.dev), `@lengkapp/edge` aims for the same class o
|
|
|
15
17
|
- [JSX Support](#jsx-support)
|
|
16
18
|
- [Full Example](#full-example)
|
|
17
19
|
- [Client (Declarative Partial Updates)](#client-declarative-partial-updates)
|
|
18
|
-
- [Device
|
|
20
|
+
- [Device Fingerprint](#device-fingerprint)
|
|
19
21
|
- [CSRF Protection](#csrf-protection)
|
|
20
22
|
- [Security Controls](#security-controls)
|
|
23
|
+
- [OWASP 2025 Coverage](#owasp-2025-coverage)
|
|
21
24
|
- [Scheduled Tasks](#scheduled-tasks)
|
|
22
25
|
- [Configuration](#configuration)
|
|
23
26
|
- [Performance](#performance)
|
|
24
|
-
- [Build](#build)
|
|
25
27
|
- [Security Posture Summary](#security-posture-summary)
|
|
26
28
|
- [License](#license)
|
|
27
29
|
|
|
30
|
+
---
|
|
31
|
+
|
|
28
32
|
## Features
|
|
29
33
|
|
|
30
34
|
### Server
|
|
31
35
|
|
|
32
|
-
- **Trie-based routing** – static
|
|
36
|
+
- **Trie-based routing** – static & dynamic routes (`/users/:id`)
|
|
33
37
|
- **JSX support** – pass JSX straight to `ctx.html(<Card />)`; no build step, no manual `renderToString` call
|
|
34
38
|
- **Middleware** – CORS, logging, caching, compression, validation
|
|
35
39
|
- **Cookie helpers** with validation
|
|
@@ -39,12 +43,13 @@ Inspired by [Hono](https://hono.dev), `@lengkapp/edge` aims for the same class o
|
|
|
39
43
|
### Client
|
|
40
44
|
|
|
41
45
|
- **Declarative partial updates** via `_get` / `_post` and the placement modes `_in`, `_out`, `_before`, `_after`
|
|
42
|
-
- **Client-side navigation** via `_go` (same tab) and `_open` (new tab)
|
|
43
|
-
- **Opt-in device
|
|
44
|
-
- **
|
|
45
|
-
- **JSON and form bodies** – `_json="
|
|
46
|
-
- **
|
|
47
|
-
- **
|
|
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
|
|
48
53
|
- **Zero dependencies**
|
|
49
54
|
|
|
50
55
|
### Security
|
|
@@ -55,6 +60,8 @@ Inspired by [Hono](https://hono.dev), `@lengkapp/edge` aims for the same class o
|
|
|
55
60
|
- **Fail-closed middleware** – validation and handler errors deny by default
|
|
56
61
|
- **Strict CORS allowlist** – per-origin reflection with `Vary: Origin`
|
|
57
62
|
|
|
63
|
+
---
|
|
64
|
+
|
|
58
65
|
## Installation
|
|
59
66
|
|
|
60
67
|
```bash
|
|
@@ -63,7 +70,7 @@ npm install @lengkapp/edge
|
|
|
63
70
|
|
|
64
71
|
## Quick Start
|
|
65
72
|
|
|
66
|
-
|
|
73
|
+
`worker.js`:
|
|
67
74
|
|
|
68
75
|
```ts
|
|
69
76
|
import { Edge } from '@lengkapp/edge';
|
|
@@ -76,14 +83,16 @@ app.get('/users/:id', (ctx) => ctx.json({ id: ctx.params.id }));
|
|
|
76
83
|
export default app;
|
|
77
84
|
```
|
|
78
85
|
|
|
79
|
-
Or, if you use JSX,
|
|
86
|
+
Or, if you will use JSX, `worker.tsx`:
|
|
80
87
|
|
|
81
88
|
```tsx
|
|
82
89
|
import { Edge, jsx, Fragment } from '@lengkapp/edge';
|
|
83
90
|
|
|
84
91
|
const app = new Edge();
|
|
85
92
|
|
|
86
|
-
const Card = () =>
|
|
93
|
+
const Card = () => (
|
|
94
|
+
<div>card</div>
|
|
95
|
+
);
|
|
87
96
|
|
|
88
97
|
const LandingPage = () => (
|
|
89
98
|
<>
|
|
@@ -97,7 +106,7 @@ app.get('/', () => <LandingPage />);
|
|
|
97
106
|
export default app;
|
|
98
107
|
```
|
|
99
108
|
|
|
100
|
-
|
|
109
|
+
If you use TypeScript, `tsconfig.json`:
|
|
101
110
|
|
|
102
111
|
```json
|
|
103
112
|
{
|
|
@@ -117,18 +126,18 @@ export default app;
|
|
|
117
126
|
}
|
|
118
127
|
```
|
|
119
128
|
|
|
120
|
-
|
|
129
|
+
`wrangler.jsonc`:
|
|
121
130
|
|
|
122
131
|
```jsonc
|
|
123
132
|
{
|
|
124
133
|
"$schema": "./node_modules/wrangler/config-schema.json",
|
|
125
134
|
"name": "my-edge-app",
|
|
126
135
|
"main": "worker.js",
|
|
127
|
-
"compatibility_date": "2026-09-
|
|
136
|
+
"compatibility_date": "2026-09-06"
|
|
128
137
|
}
|
|
129
138
|
```
|
|
130
139
|
|
|
131
|
-
Or
|
|
140
|
+
Or `wrangler.toml`:
|
|
132
141
|
|
|
133
142
|
```toml
|
|
134
143
|
name = "my-edge-app"
|
|
@@ -147,49 +156,25 @@ wrangler deploy
|
|
|
147
156
|
### Context
|
|
148
157
|
|
|
149
158
|
| Member | Description |
|
|
150
|
-
|
|
151
|
-
| `ctx.req` | Incoming
|
|
159
|
+
|---|---|
|
|
160
|
+
| `ctx.req` | Incoming Request |
|
|
152
161
|
| `ctx.env` | Environment bindings |
|
|
153
|
-
| `ctx.executionCtx` |
|
|
162
|
+
| `ctx.executionCtx` | ExecutionContext |
|
|
154
163
|
| `ctx.params` | Prototype-safe route params object |
|
|
155
|
-
| `ctx.status` | Default response status (
|
|
156
|
-
| `ctx.headers` | Response
|
|
157
|
-
| `ctx.query` |
|
|
158
|
-
| `ctx.url` | Parsed
|
|
164
|
+
| `ctx.status` | Default response status (200) |
|
|
165
|
+
| `ctx.headers` | Response Headers |
|
|
166
|
+
| `ctx.query` | URLSearchParams |
|
|
167
|
+
| `ctx.url` | Parsed URL object |
|
|
159
168
|
| `ctx.getCookie(name)` | Read a cookie |
|
|
160
169
|
| `ctx.setCookie(name, value, options)` | Set a cookie (name validated) |
|
|
161
170
|
| `ctx.deleteCookie(name, options)` | Delete a cookie |
|
|
162
171
|
| `ctx.text(data, status?, headers?)` | Plain-text response |
|
|
163
172
|
| `ctx.json(data, status?, headers?)` | JSON response |
|
|
164
|
-
| `ctx.html(data, status?, headers?)` | HTML response — accepts a raw string, a JSX element, or an array of JSX elements
|
|
165
|
-
| `ctx.
|
|
166
|
-
| `ctx.redirect(location, status?)` | Redirect (default `302`), preserving headers already set on the context |
|
|
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 |
|
|
167
175
|
|
|
168
176
|
Every response carries `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, and a restrictive `Permissions-Policy`.
|
|
169
177
|
|
|
170
|
-
### Ambient Context (`useCtx`)
|
|
171
|
-
|
|
172
|
-
`useCtx()` returns the `Context` of the request whose render is currently executing. It is backed by a synchronous stack — no `AsyncLocalStorage`, no compatibility flags.
|
|
173
|
-
|
|
174
|
-
```tsx
|
|
175
|
-
import { useCtx } from '@lengkapp/edge';
|
|
176
|
-
|
|
177
|
-
async function User() {
|
|
178
|
-
// MUST be called synchronously, before any await.
|
|
179
|
-
const ctx = useCtx();
|
|
180
|
-
const res = await fetch(`https://api.example.com/users/${ctx.params.id}`);
|
|
181
|
-
const data = await res.json();
|
|
182
|
-
// After the await, use the captured local `ctx` — do not call useCtx() again.
|
|
183
|
-
return <div>{data.name}</div>;
|
|
184
|
-
}
|
|
185
|
-
```
|
|
186
|
-
|
|
187
|
-
**Contract:**
|
|
188
|
-
|
|
189
|
-
- Call `useCtx()` at the top of a component, before any `await`.
|
|
190
|
-
- After an `await`, use the captured local reference.
|
|
191
|
-
- Throws if called outside an active render (module top-level, `scheduled` handler, `waitUntil` callback).
|
|
192
|
-
|
|
193
178
|
### Route Options
|
|
194
179
|
|
|
195
180
|
```ts
|
|
@@ -200,20 +185,20 @@ app.get('/logged', { log: true }, handler);
|
|
|
200
185
|
app.post('/submit', { validate: (ctx) => /* ... */ true }, handler);
|
|
201
186
|
```
|
|
202
187
|
|
|
203
|
-
|
|
204
|
-
| --- | --- |
|
|
205
|
-
| `cache` | `{ ttl, staleWhileRevalidate }` — TTL in seconds, default `3600`. Only applied to `GET` responses with status `200`. `Set-Cookie` is stripped from cached responses. |
|
|
206
|
-
| `cors` | Default is `origin: '*'`. For production, use a strict allowlist (see below). |
|
|
207
|
-
| `compress` | Negotiates `gzip` or `deflate` from `Accept-Encoding` using the native `CompressionStream`. |
|
|
208
|
-
| `log` | Logs `METHOD URL - STATUS` to the console. |
|
|
209
|
-
| `validate` | Receives the `Context`; return `true` to allow, or `false` / a falsy value to reject with `400 Validation failed`. Async validators are awaited. Thrown errors are logged and treated as a rejection. |
|
|
188
|
+
**Cache:** `{ ttl, staleWhileRevalidate }` — TTL in seconds, default 3600; only applied to GET responses with status 200. `Set-Cookie` is stripped from cached responses.
|
|
210
189
|
|
|
211
|
-
|
|
190
|
+
**CORS:** The default is `origin: '*'`. For production, use a strict allowlist:
|
|
212
191
|
|
|
213
192
|
```ts
|
|
214
193
|
app.defaults.cors.origin = ['https://app.example.com'];
|
|
215
194
|
```
|
|
216
195
|
|
|
196
|
+
**Compress:** Negotiates gzip or deflate from `Accept-Encoding` using the native `CompressionStream`.
|
|
197
|
+
|
|
198
|
+
**Log:** Logs `METHOD URL - STATUS` to the console.
|
|
199
|
+
|
|
200
|
+
**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.
|
|
201
|
+
|
|
217
202
|
## JSX Support
|
|
218
203
|
|
|
219
204
|
`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.
|
|
@@ -243,10 +228,10 @@ app.get('/', () => <LandingPage />);
|
|
|
243
228
|
```tsx
|
|
244
229
|
import { renderToString } from '@lengkapp/edge';
|
|
245
230
|
|
|
246
|
-
const html =
|
|
231
|
+
const html = renderToString(<Card title="Hello" />);
|
|
247
232
|
```
|
|
248
233
|
|
|
249
|
-
|
|
234
|
+
**`renderToString` hardening:**
|
|
250
235
|
|
|
251
236
|
- Tag names must match `^[A-Za-z][A-Za-z0-9-]*$`.
|
|
252
237
|
- Attribute names must match `^[A-Za-z_:][A-Za-z0-9_:.-]*$`.
|
|
@@ -254,13 +239,10 @@ const html = await renderToString(<Card title="Hello" />);
|
|
|
254
239
|
- `href` / `src` / `action` / `formaction` / `xlink:href` values beginning with `javascript:`, `vbscript:`, or `data:text/html` are stripped.
|
|
255
240
|
- Prototype keys (`__proto__`, `constructor`, `prototype`) are rejected.
|
|
256
241
|
- All string values are HTML-escaped.
|
|
257
|
-
|
|
258
|
-
### Rendering details
|
|
259
|
-
|
|
260
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`, …).
|
|
261
243
|
- **Aliases.** `className` → `class`, `htmlFor` → `for`.
|
|
262
244
|
- **Boolean attributes.** `checked`, `disabled`, `required`, `readonly`, `multiple`, etc. emit as bare attributes when `true` and are dropped when `false`.
|
|
263
|
-
- **`dangerouslySetInnerHTML`.** Supported via `dangerouslySetInnerHTML={{ __html: '…' }}` — the value is inserted verbatim and is
|
|
245
|
+
- **`dangerouslySetInnerHTML`.** Supported via `dangerouslySetInnerHTML={{ __html: '…' }}` — the value is inserted verbatim and is not escaped. Only use it with trusted content.
|
|
264
246
|
|
|
265
247
|
## Full Example
|
|
266
248
|
|
|
@@ -272,7 +254,7 @@ A single file that exercises every server feature.
|
|
|
272
254
|
// Demonstrates every feature of @lengkapp/edge:
|
|
273
255
|
// - static & dynamic routes, all HTTP methods
|
|
274
256
|
// - params, query, cookies (get/set/delete)
|
|
275
|
-
// - ctx.text / ctx.json / ctx.html / ctx.
|
|
257
|
+
// - ctx.text / ctx.json / ctx.html / ctx.redirect
|
|
276
258
|
// - JSX rendering (elements, Fragments, function components, arrays)
|
|
277
259
|
// - style objects, boolean attributes, void elements,
|
|
278
260
|
// className/htmlFor aliases, dangerouslySetInnerHTML
|
|
@@ -280,14 +262,12 @@ A single file that exercises every server feature.
|
|
|
280
262
|
// - security.extraHeaders, security.logSecurityEvents
|
|
281
263
|
// - scheduled handler
|
|
282
264
|
// - returning JSX directly from a handler
|
|
283
|
-
// - useCtx() inside an async component
|
|
284
265
|
|
|
285
266
|
import {
|
|
286
267
|
Edge,
|
|
287
268
|
Context,
|
|
288
269
|
Fragment,
|
|
289
270
|
renderToString,
|
|
290
|
-
useCtx,
|
|
291
271
|
type JSXNode,
|
|
292
272
|
type RouteOptions,
|
|
293
273
|
} from '@lengkapp/edge';
|
|
@@ -302,7 +282,6 @@ function Layout(props: { title: string; children?: any }) {
|
|
|
302
282
|
<head>
|
|
303
283
|
<meta charset="utf-8" />
|
|
304
284
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
305
|
-
<meta name="_csrf" content="token-here" />
|
|
306
285
|
<title>{props.title}</title>
|
|
307
286
|
</head>
|
|
308
287
|
<body>
|
|
@@ -340,14 +319,6 @@ function TodoList(props: { items: string[] }) {
|
|
|
340
319
|
);
|
|
341
320
|
}
|
|
342
321
|
|
|
343
|
-
// Async component using useCtx() synchronously before any await.
|
|
344
|
-
async function CurrentUser() {
|
|
345
|
-
const ctx = useCtx();
|
|
346
|
-
const id = ctx.params.id ?? 'me';
|
|
347
|
-
// ...fetch with ctx.env or ctx.req...
|
|
348
|
-
return <UserCard id={id} name={`User ${id}`} />;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
322
|
/* ------------------------------------------------------------------ *
|
|
352
323
|
* App *
|
|
353
324
|
* ------------------------------------------------------------------ */
|
|
@@ -358,15 +329,17 @@ const app = new Edge();
|
|
|
358
329
|
app.security.logSecurityEvents = true;
|
|
359
330
|
app.security.extraHeaders = {
|
|
360
331
|
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
|
|
361
|
-
'X-Custom-Powered-By': '
|
|
332
|
+
'X-Custom-Powered-By': 'edge-server',
|
|
362
333
|
};
|
|
363
334
|
|
|
364
335
|
/* ================================================================== *
|
|
365
336
|
* Basic routes *
|
|
366
337
|
* ================================================================== */
|
|
367
338
|
|
|
339
|
+
// Plain text
|
|
368
340
|
app.get('/health', (ctx) => ctx.text('ok'));
|
|
369
341
|
|
|
342
|
+
// JSON with a custom status
|
|
370
343
|
app.get('/api/time', (ctx) =>
|
|
371
344
|
ctx.json({ now: new Date().toISOString() }, 200)
|
|
372
345
|
);
|
|
@@ -375,7 +348,7 @@ app.get('/api/time', (ctx) =>
|
|
|
375
348
|
// a text/html Response.
|
|
376
349
|
app.get('/', () => (
|
|
377
350
|
<Layout title="Home">
|
|
378
|
-
<h1>Hello from
|
|
351
|
+
<h1>Hello from edge-server</h1>
|
|
379
352
|
<p>This page was rendered from JSX.</p>
|
|
380
353
|
<TodoList items={['Write routes', 'Render JSX', 'Ship it']} />
|
|
381
354
|
</Layout>
|
|
@@ -386,7 +359,10 @@ app.get('/about', (ctx) =>
|
|
|
386
359
|
ctx.html(
|
|
387
360
|
<Layout title="About">
|
|
388
361
|
<h1>About</h1>
|
|
389
|
-
<p>
|
|
362
|
+
<p>
|
|
363
|
+
Fragments, components, arrays — all supported.
|
|
364
|
+
</p>
|
|
365
|
+
{/* Array of JSX is allowed inside a fragment */}
|
|
390
366
|
<Fragment>
|
|
391
367
|
<UserCard id="1" name="Ada" admin />
|
|
392
368
|
<UserCard id="2" name="Grace" />
|
|
@@ -395,11 +371,6 @@ app.get('/about', (ctx) =>
|
|
|
395
371
|
)
|
|
396
372
|
);
|
|
397
373
|
|
|
398
|
-
// ctx.page prepends <!DOCTYPE html> if missing
|
|
399
|
-
app.get('/page', (ctx) =>
|
|
400
|
-
ctx.page(<Layout title="Page"><h1>Full document</h1></Layout>)
|
|
401
|
-
);
|
|
402
|
-
|
|
403
374
|
// ctx.html also accepts a raw HTML string (passes through unchanged)
|
|
404
375
|
app.get('/raw', (ctx) =>
|
|
405
376
|
ctx.html('<h1>Raw HTML</h1><p>Not escaped.</p>')
|
|
@@ -410,7 +381,7 @@ app.get('/raw', (ctx) =>
|
|
|
410
381
|
* ================================================================== */
|
|
411
382
|
|
|
412
383
|
// Dynamic route: /users/:id
|
|
413
|
-
app.get('/users/:id',
|
|
384
|
+
app.get('/users/:id', (ctx) => {
|
|
414
385
|
const { id } = ctx.params;
|
|
415
386
|
return ctx.html(
|
|
416
387
|
<Layout title={`User ${id}`}>
|
|
@@ -419,11 +390,6 @@ app.get('/users/:id', async (ctx) => {
|
|
|
419
390
|
);
|
|
420
391
|
});
|
|
421
392
|
|
|
422
|
-
// Async component + useCtx()
|
|
423
|
-
app.get('/me/:id', (ctx) =>
|
|
424
|
-
ctx.html(<Layout title="Me"><CurrentUser /></Layout>)
|
|
425
|
-
);
|
|
426
|
-
|
|
427
393
|
// Multiple params: /posts/:year/:slug
|
|
428
394
|
app.get('/posts/:year/:slug', (ctx) => {
|
|
429
395
|
const { year, slug } = ctx.params;
|
|
@@ -488,42 +454,51 @@ app.delete('/api/items/:id', (ctx) =>
|
|
|
488
454
|
);
|
|
489
455
|
|
|
490
456
|
app.options('/api/items', (ctx) => ctx.text('', 204));
|
|
457
|
+
|
|
491
458
|
app.head('/api/items', (ctx) => ctx.text('', 200));
|
|
492
459
|
|
|
493
460
|
/* ================================================================== *
|
|
494
461
|
* Route options: cors, cache, compress, log, validate *
|
|
495
462
|
* ================================================================== */
|
|
496
463
|
|
|
464
|
+
// CORS with a wildcard origin
|
|
497
465
|
app.get(
|
|
498
466
|
'/cors-open',
|
|
499
467
|
{ cors: true, log: true },
|
|
500
468
|
(ctx) => ctx.json({ cors: 'wildcard' })
|
|
501
469
|
);
|
|
502
470
|
|
|
471
|
+
// CORS with an allow-list + credentials
|
|
503
472
|
app.get(
|
|
504
473
|
'/cors-restricted',
|
|
505
474
|
{
|
|
506
475
|
cors: {
|
|
507
476
|
origin: ['https://app.example.com', 'https://admin.example.com'],
|
|
508
477
|
methods: 'GET, POST',
|
|
509
|
-
headers: 'Content-Type, X-CSRF-Token
|
|
478
|
+
headers: 'Content-Type, X-CSRF-Token',
|
|
510
479
|
},
|
|
511
480
|
},
|
|
512
481
|
(ctx) => ctx.json({ cors: 'restricted' })
|
|
513
482
|
);
|
|
514
483
|
|
|
484
|
+
// Caching: cache the GET response for 60s, revalidate in background
|
|
515
485
|
app.get(
|
|
516
486
|
'/cached',
|
|
517
|
-
{
|
|
487
|
+
{
|
|
488
|
+
cache: { ttl: 60, staleWhileRevalidate: 30 },
|
|
489
|
+
log: true,
|
|
490
|
+
},
|
|
518
491
|
(ctx) => ctx.json({ generatedAt: Date.now() })
|
|
519
492
|
);
|
|
520
493
|
|
|
494
|
+
// Compression (gzip / deflate based on Accept-Encoding)
|
|
521
495
|
app.get(
|
|
522
496
|
'/big',
|
|
523
497
|
{ compress: true },
|
|
524
498
|
(ctx) => ctx.html(`<pre>${'x'.repeat(5000)}</pre>`)
|
|
525
499
|
);
|
|
526
500
|
|
|
501
|
+
// Request validation — return false to get a 400 automatically
|
|
527
502
|
app.post(
|
|
528
503
|
'/admin',
|
|
529
504
|
{
|
|
@@ -535,6 +510,7 @@ app.post(
|
|
|
535
510
|
(ctx) => ctx.json({ ok: true })
|
|
536
511
|
);
|
|
537
512
|
|
|
513
|
+
// Everything combined
|
|
538
514
|
const everythingOptions: RouteOptions = {
|
|
539
515
|
cors: { origin: '*' },
|
|
540
516
|
cache: { ttl: 120, staleWhileRevalidate: 60 },
|
|
@@ -558,6 +534,7 @@ app.get('/everything', everythingOptions, (ctx) =>
|
|
|
558
534
|
app.get('/jsx/gallery', (ctx) =>
|
|
559
535
|
ctx.html(
|
|
560
536
|
<Layout title="JSX Gallery">
|
|
537
|
+
{/* Style objects → kebab-cased, numbers get px added */}
|
|
561
538
|
<div
|
|
562
539
|
style={{
|
|
563
540
|
backgroundColor: 'tomato',
|
|
@@ -569,31 +546,44 @@ app.get('/jsx/gallery', (ctx) =>
|
|
|
569
546
|
Styled box
|
|
570
547
|
</div>
|
|
571
548
|
|
|
572
|
-
|
|
549
|
+
{/* className and htmlFor are aliased to class / for */}
|
|
550
|
+
<label className="lbl" htmlFor="name">
|
|
551
|
+
Name
|
|
552
|
+
</label>
|
|
573
553
|
<input id="name" type="text" required disabled={false} />
|
|
574
554
|
|
|
555
|
+
{/* Boolean attributes: true emits the bare attribute */}
|
|
575
556
|
<input type="checkbox" checked readOnly />
|
|
576
557
|
<button disabled>Nope</button>
|
|
577
558
|
|
|
559
|
+
{/* Void elements self-close */}
|
|
578
560
|
<img src="/logo.png" alt="logo" />
|
|
579
561
|
<br />
|
|
580
562
|
<hr />
|
|
581
563
|
|
|
564
|
+
{/* dangerouslySetInnerHTML */}
|
|
582
565
|
<div dangerouslySetInnerHTML={{ __html: '<b>trusted</b>' }} />
|
|
583
566
|
|
|
567
|
+
{/* Fragments */}
|
|
584
568
|
<>
|
|
585
569
|
<p>Fragment child A</p>
|
|
586
570
|
<p>Fragment child B</p>
|
|
587
571
|
</>
|
|
588
572
|
|
|
573
|
+
{/* Arrays of JSX */}
|
|
589
574
|
{[<span key="a">A</span>, <span key="b">B</span>, <span key="c">C</span>]}
|
|
590
575
|
|
|
576
|
+
{/* Escaping: user-supplied strings are escaped */}
|
|
591
577
|
<p>{'<script>alert(1)</script>'}</p>
|
|
592
578
|
|
|
579
|
+
{/* Dangerous URLs are dropped */}
|
|
593
580
|
<a href="javascript:alert(1)">nope</a>
|
|
594
581
|
<a href="https://example.com">ok</a>
|
|
595
582
|
|
|
583
|
+
{/* Numbers are stringified and escaped */}
|
|
596
584
|
<p>Count: {42}</p>
|
|
585
|
+
|
|
586
|
+
{/* null / undefined / booleans render nothing */}
|
|
597
587
|
<p>{null}{undefined}{false}{true}</p>
|
|
598
588
|
</Layout>
|
|
599
589
|
)
|
|
@@ -603,8 +593,8 @@ app.get('/jsx/gallery', (ctx) =>
|
|
|
603
593
|
* renderToString() standalone *
|
|
604
594
|
* ================================================================== */
|
|
605
595
|
|
|
606
|
-
app.get('/jsx/string',
|
|
607
|
-
const html =
|
|
596
|
+
app.get('/jsx/string', (ctx) => {
|
|
597
|
+
const html = renderToString(
|
|
608
598
|
<section>
|
|
609
599
|
<h1>Rendered manually</h1>
|
|
610
600
|
<p>Via renderToString()</p>
|
|
@@ -619,6 +609,7 @@ app.get('/jsx/string', async (ctx) => {
|
|
|
619
609
|
|
|
620
610
|
app.scheduled(async (event, env, ctx) => {
|
|
621
611
|
console.log('cron fired at', new Date(event.scheduledTime).toISOString());
|
|
612
|
+
// e.g. warm a cache, prune KV entries, etc.
|
|
622
613
|
});
|
|
623
614
|
|
|
624
615
|
/* ================================================================== *
|
|
@@ -636,11 +627,10 @@ export default {
|
|
|
636
627
|
### Feature → route cheat-sheet
|
|
637
628
|
|
|
638
629
|
| Feature | Route / location |
|
|
639
|
-
|
|
630
|
+
|---|---|
|
|
640
631
|
| `ctx.text` | `GET /health` |
|
|
641
632
|
| `ctx.json` | `GET /api/time`, `POST /api/echo`, … |
|
|
642
633
|
| `ctx.html` with JSX | `GET /` |
|
|
643
|
-
| `ctx.page` | `GET /page` |
|
|
644
634
|
| `ctx.html` with string | `GET /raw` |
|
|
645
635
|
| Returning JSX directly | `GET /` |
|
|
646
636
|
| `ctx.redirect` | `GET /login`, `GET /logout`, `GET /dashboard` |
|
|
@@ -655,9 +645,8 @@ export default {
|
|
|
655
645
|
| `validate` | `POST /admin`, `/everything` |
|
|
656
646
|
| `security.extraHeaders` | set once near the top |
|
|
657
647
|
| `security.logSecurityEvents` | set once near the top |
|
|
658
|
-
| `useCtx()` | `CurrentUser` component, `GET /me/:id` |
|
|
659
648
|
| Fragments | `GET /about`, `GET /jsx/gallery` |
|
|
660
|
-
| Function components | `Layout`, `UserCard`, `TodoList
|
|
649
|
+
| Function components | `Layout`, `UserCard`, `TodoList` |
|
|
661
650
|
| Style objects | `GET /jsx/gallery` |
|
|
662
651
|
| Boolean attrs / void elements | `GET /jsx/gallery` |
|
|
663
652
|
| `dangerouslySetInnerHTML` | `GET /jsx/gallery` |
|
|
@@ -673,23 +662,25 @@ export default {
|
|
|
673
662
|
### Attributes
|
|
674
663
|
|
|
675
664
|
| Attribute | Description |
|
|
676
|
-
|
|
677
|
-
| `_get` / `_post` | Request URL and HTTP method. Only
|
|
678
|
-
| `_go` | Navigate the current tab to the URL (`location.
|
|
679
|
-
| `_open` | Open the URL in a new tab (`
|
|
680
|
-
| `_id` | Opt in to
|
|
665
|
+
|---|---|
|
|
666
|
+
| `_get` / `_post` | Request URL and HTTP method. Only GET and POST are supported. |
|
|
667
|
+
| `_go` | Navigate the current tab to the URL (`location.assign`). |
|
|
668
|
+
| `_open` | Open the URL in a new tab (`noopener,noreferrer`). |
|
|
669
|
+
| `_id` | Opt in to the device fingerprint for this element. See [Device Fingerprint](#device-fingerprint). |
|
|
681
670
|
| `_in` | Replace the target's children with the response. |
|
|
682
|
-
| `_out` | Replace the target element itself
|
|
671
|
+
| `_out` | Replace the target element itself. |
|
|
683
672
|
| `_before` | Insert the response before the target. |
|
|
684
673
|
| `_after` | Insert the response after the target. |
|
|
685
|
-
| `
|
|
686
|
-
| `
|
|
687
|
-
| `
|
|
688
|
-
| `
|
|
674
|
+
| `_toast` | Insert the response non 2xx to toast element if any. |
|
|
675
|
+
| `_trigger` | `click` (default), `load`, `visible`, or any DOM event name. |
|
|
676
|
+
| `_form` | CSS selector or element ID of a form to serialize as the body. |
|
|
677
|
+
| `_json` | Comma-separated field names to send as a JSON body. |
|
|
678
|
+
| `_loader` | `spinner` (default), `skeleton`, or `none` / `off` / `false` to disable. |
|
|
679
|
+
| `_timeout` | Request timeout in milliseconds (default 20000). |
|
|
689
680
|
|
|
690
|
-
**Targets.** Exactly one placement attribute (`_in`, `_out`, `_before`, `_after`) should be present
|
|
681
|
+
**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.
|
|
691
682
|
|
|
692
|
-
|
|
683
|
+
**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).
|
|
693
684
|
|
|
694
685
|
### Examples
|
|
695
686
|
|
|
@@ -698,118 +689,99 @@ export default {
|
|
|
698
689
|
<button _get="/more-posts" _in="#posts">Load More</button>
|
|
699
690
|
|
|
700
691
|
<!-- replace this element with the response -->
|
|
701
|
-
<div _get="/user-profile" _out="
|
|
692
|
+
<div _get="/user-profile" _out="this"></div>
|
|
702
693
|
|
|
703
|
-
<!-- POST JSON built from
|
|
704
|
-
<button _post="/login" _json="
|
|
694
|
+
<!-- POST JSON built from form fields, replace the children of #status -->
|
|
695
|
+
<button _post="/login" _json="username,password" _in="#status">Login</button>
|
|
705
696
|
|
|
706
697
|
<!-- POST a whole form -->
|
|
707
698
|
<form id="signup">…</form>
|
|
708
699
|
<button _post="/signup" _form="#signup" _in="#result">Sign up</button>
|
|
709
700
|
|
|
710
701
|
<!-- fetch lazily when the element scrolls into view -->
|
|
711
|
-
<div _get="/lazy" _in="
|
|
702
|
+
<div _get="/lazy" _in="this" _trigger="visible"></div>
|
|
712
703
|
|
|
713
|
-
<!--
|
|
714
|
-
<
|
|
704
|
+
<!-- skeleton loader with a 5-second timeout -->
|
|
705
|
+
<div _get="/feed" _in="this" _loader="skeleton" _timeout="5000"></div>
|
|
706
|
+
|
|
707
|
+
<!-- _toast if non 2xx -->
|
|
708
|
+
<div _get="/feed-error" _in="this" _toast="#toast"></div>
|
|
709
|
+
|
|
710
|
+
<!-- same-tab navigation with a device id -->
|
|
711
|
+
<a href="/dashboard" _go _id>Dashboard</a>
|
|
715
712
|
|
|
716
713
|
<!-- new-tab navigation -->
|
|
717
714
|
<a href="/docs" _open>Docs</a>
|
|
718
715
|
|
|
719
|
-
<!--
|
|
716
|
+
<!-- POST with X-DeviceId header -->
|
|
720
717
|
<button _post="/like" _id _in="#card-3">Like</button>
|
|
721
|
-
|
|
722
|
-
<!-- on error, drop the response body into a toast host -->
|
|
723
|
-
<button _post="/submit" _form="#f" _toast="#toasts">Submit</button>
|
|
724
718
|
```
|
|
725
719
|
|
|
726
720
|
### Triggers
|
|
727
721
|
|
|
728
|
-
-
|
|
729
|
-
-
|
|
730
|
-
-
|
|
722
|
+
- `click` (default) — handled by a single delegated document listener.
|
|
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.
|
|
731
725
|
|
|
732
726
|
### How Content Is Inserted
|
|
733
727
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
- Non-async external scripts (classic and `type="module"`) are awaited via their `load` / `error` events, with a 30-second safety timeout so a stuck URL cannot wedge the queue.
|
|
745
|
-
9. Newly inserted `[_get]` / `[_post]` / `[_go]` / `[_open]` elements are picked up by a `MutationObserver` on `document.documentElement`.
|
|
728
|
+
- The request is sent with `credentials: 'same-origin'` and `X-Requested-With: XMLHttpRequest`.
|
|
729
|
+
- When the triggering element carries `_id`, the request includes an `X-DeviceId` header (see [Device Fingerprint](#device-fingerprint)).
|
|
730
|
+
- For `_post`, the body is JSON (`_json`), a `FormData` object (`_form`), or empty.
|
|
731
|
+
- The response text is parsed into a `<template>`.
|
|
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.
|
|
746
738
|
|
|
747
739
|
> **Note:** No content-type check, HTML sanitization, or CSRF token is applied by the client — it trusts the server's response and inserts it as-is. Treat the partial-HTML endpoints you point `_get` / `_post` at as part of your trusted surface.
|
|
748
740
|
|
|
749
741
|
### Loader & Error States
|
|
750
742
|
|
|
751
|
-
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
- `_after` — the spinner is inserted immediately after the target.
|
|
755
|
-
- The spinner is removed when the response (or error) lands.
|
|
743
|
+
- The loader is deferred by 100ms: if the response lands before then, no loader is shown at all.
|
|
744
|
+
- Once shown, the loader stays for at least 240ms before the content swaps in, so it never flashes.
|
|
745
|
+
- `_loader="spinner"` (default), `_loader="skeleton"` for a shimmering skeleton, or `_loader="none"` to disable.
|
|
756
746
|
- A new request on the same element aborts the previous one via `AbortController`.
|
|
757
|
-
- On failure — non-2xx, network error, or
|
|
758
|
-
-
|
|
759
|
-
- The stylesheet is injected once and defines `._edge-loader`, `._edge-error`, and a `_edge-spin` keyframe.
|
|
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.
|
|
760
749
|
|
|
761
|
-
|
|
750
|
+
### View Transitions
|
|
762
751
|
|
|
763
|
-
|
|
752
|
+
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`.
|
|
764
753
|
|
|
765
|
-
|
|
754
|
+
## Device Fingerprint
|
|
766
755
|
|
|
767
|
-
|
|
768
|
-
2. Otherwise returns `new Date().toString()`.
|
|
756
|
+
Add `_id` to any `_get` / `_post` / `_go` / `_open` element to attach a stable device identifier.
|
|
769
757
|
|
|
770
|
-
`
|
|
758
|
+
**Signals.** `navigator.userAgent`, `navigator.language`, screen dimensions, color depth, timezone offset, `hardwareConcurrency`, `deviceMemory`, plus Canvas, WebGL renderer, an offline Audio context, and a 10-font width probe. Combined and SHA-256 hashed.
|
|
771
759
|
|
|
772
|
-
|
|
773
|
-
// Optional: install your own stable identifier.
|
|
774
|
-
declare global {
|
|
775
|
-
interface Window {
|
|
776
|
-
deviceId?: () => string;
|
|
777
|
-
}
|
|
778
|
-
}
|
|
760
|
+
**Cost.** Computed lazily once per page and cached for the document lifetime; warmed at boot if any `[_id]` element is in the DOM. Adds ~0.9 KB gzipped to the client bundle and a few milliseconds on the first call.
|
|
779
761
|
|
|
780
|
-
|
|
781
|
-
let id = localStorage.getItem('device-id');
|
|
782
|
-
if (!id) {
|
|
783
|
-
id = crypto.randomUUID();
|
|
784
|
-
localStorage.setItem('device-id', id);
|
|
785
|
-
}
|
|
786
|
-
return id;
|
|
787
|
-
};
|
|
788
|
-
```
|
|
762
|
+
**Delivery.**
|
|
789
763
|
|
|
790
|
-
|
|
764
|
+
- `_get` / `_post` → sent as the `X-DeviceId` request header (no body, no URL change).
|
|
765
|
+
- `_go` / `_open` → appended to the URL as `?_did=<hash>` (headers cannot be set on `location.assign` / `window.open`).
|
|
791
766
|
|
|
792
|
-
|
|
767
|
+
**Fallback.** If `crypto.subtle` is unavailable (non-secure context), a djb2 hash of the same signals is used, with lower collision resistance.
|
|
793
768
|
|
|
794
|
-
|
|
769
|
+
Without `_id`, no fingerprint is computed, no header is set, and no URL parameter is added.
|
|
795
770
|
|
|
796
|
-
|
|
797
|
-
<meta name="_csrf" content="…">
|
|
798
|
-
```
|
|
771
|
+
## CSRF Protection
|
|
799
772
|
|
|
800
|
-
|
|
773
|
+
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:
|
|
801
774
|
|
|
802
775
|
```ts
|
|
803
776
|
app.post('/submit', {
|
|
804
|
-
validate: (ctx) => {
|
|
805
|
-
const
|
|
806
|
-
|
|
807
|
-
return header.length > 0 && header === cookie;
|
|
777
|
+
validate: async (ctx) => {
|
|
778
|
+
const body = await ctx.req.json().catch(() => ({}));
|
|
779
|
+
return body.csrf_token && body.csrf_token === ctx.getCookie('csrf_token');
|
|
808
780
|
}
|
|
809
781
|
}, handler);
|
|
810
782
|
```
|
|
811
783
|
|
|
812
|
-
|
|
784
|
+
For `_form`-based submissions, read the field from the parsed form data using the same pattern.
|
|
813
785
|
|
|
814
786
|
## Security Controls
|
|
815
787
|
|
|
@@ -826,14 +798,14 @@ app.security.extraHeaders = {
|
|
|
826
798
|
};
|
|
827
799
|
```
|
|
828
800
|
|
|
829
|
-
|
|
801
|
+
## OWASP 2025 Coverage
|
|
830
802
|
|
|
831
803
|
| Category | Mitigation |
|
|
832
|
-
|
|
833
|
-
| A01 Broken Access Control | Strict CORS allowlist,
|
|
804
|
+
|---|---|
|
|
805
|
+
| A01 Broken Access Control | Strict CORS allowlist, strict target resolution, no implicit trust |
|
|
834
806
|
| A02 Security Misconfiguration | Safe default headers, strict CORS allowlist, `Vary: Origin` |
|
|
835
|
-
| A03 Supply Chain | Zero
|
|
836
|
-
| A04 Crypto Failures |
|
|
807
|
+
| A03 Supply Chain | Zero deps |
|
|
808
|
+
| A04 Crypto Failures | Standard Web Crypto only; no home-grown crypto |
|
|
837
809
|
| A05 Injection / XSS | Prototype-safe objects, JSX attribute sanitization |
|
|
838
810
|
| A06 Insecure Design | Fail-closed validation, explicit response modes |
|
|
839
811
|
| A07 Auth Failures | Validation errors are surfaced, not swallowed |
|
|
@@ -859,7 +831,7 @@ export default {
|
|
|
859
831
|
The `Edge` constructor takes no arguments. Behaviour is configured through public properties and route options:
|
|
860
832
|
|
|
861
833
|
| Property | Type | Default |
|
|
862
|
-
|
|
834
|
+
|---|---|---|
|
|
863
835
|
| `app.defaults.cors` | `{ origin, methods }` | `origin: '*'`, `methods: 'GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD'` |
|
|
864
836
|
| `app.security.logSecurityEvents` | `boolean` | `true` |
|
|
865
837
|
| `app.security.extraHeaders` | `Record<string, string> \| null` | `null` |
|
|
@@ -874,40 +846,37 @@ app.security.extraHeaders = {
|
|
|
874
846
|
|
|
875
847
|
## Performance
|
|
876
848
|
|
|
877
|
-
Zero runtime dependencies. On a local `wrangler dev` benchmark with `autocannon` (10 connections, 10s per route), throughput sits in the same tier as
|
|
849
|
+
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:
|
|
878
850
|
|
|
879
|
-
| Framework |
|
|
880
|
-
|
|
881
|
-
|
|
|
851
|
+
| Framework | /text req/s | /json req/s |
|
|
852
|
+
|---|---|---|
|
|
853
|
+
| @lengkapp/edge | 503 | 500 |
|
|
882
854
|
| Hono | 506 | 495 |
|
|
883
855
|
| Hono (tiny) | 505 | 498 |
|
|
884
856
|
| Native Workers | 498 | 500 |
|
|
885
857
|
|
|
886
858
|
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.
|
|
887
859
|
|
|
888
|
-
The JSX renderer is tuned for throughput: single-pass escaping (`charCodeAt` scan with a fast no-op path), direct string concatenation instead of intermediate arrays, cached camelCase → kebab-case conversions for style objects, and a
|
|
860
|
+
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.
|
|
889
861
|
|
|
890
|
-
The client is equally lean: it injects a single stylesheet
|
|
862
|
+
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.
|
|
891
863
|
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
`build.mjs` bundles `client.js` and `server.js` with esbuild (`bundle: false`, `format: "esm"`, `target: "es2022"`, `minify: true`), then runs Terser with `module: true`, `compress.passes: 2`, and `mangle.toplevel: true` for a second pass. A `/*! … */` banner containing the LICENSE text is prepended to each file, and `client.d.ts`, `server.d.ts`, `README.md`, and `LICENSE` are copied into `dist/`.
|
|
864
|
+
The device fingerprint is opt-in per element (`_id`) and cached for the document lifetime; it is warmed during boot when any `[_id]` element exists, so the first click is not delayed by the ~5–15 ms of Canvas / Audio work.
|
|
895
865
|
|
|
896
866
|
## Security Posture Summary
|
|
897
867
|
|
|
898
868
|
| Layer | Mechanism |
|
|
899
|
-
|
|
869
|
+
|---|---|
|
|
900
870
|
| HTML insertion (client) | Response text parsed into a `<template>`; placement driven by `_in` / `_out` / `_before` / `_after` |
|
|
901
|
-
| Script execution (client) | `<script>` elements
|
|
902
|
-
| Request hygiene (client) | `X-
|
|
903
|
-
| Cookies |
|
|
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 |
|
|
904
874
|
| Server response headers | `nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy` |
|
|
905
875
|
| Server middleware | Fail-closed validation; structured security logs; strict CORS allowlist |
|
|
906
|
-
| Prototype pollution | `Object.create(null)` + forbidden-key filtering in params, cookies, JSX
|
|
907
|
-
| Build |
|
|
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 |
|
|
908
878
|
|
|
909
879
|
## License
|
|
910
880
|
|
|
911
|
-
MIT — see [LICENSE](./LICENSE) for the full text.
|
|
912
|
-
|
|
881
|
+
MIT — see [LICENSE](./LICENSE) for the full text.
|
|
913
882
|
© LengkApp — Yasir Haris
|