@lengkapp/edge 0.0.14 → 0.0.15

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 CHANGED
@@ -1,673 +1,514 @@
1
- # @lengkapp/edge
2
-
3
- A minimal, high-performance framework for Cloudflare Workers with built-in server-side rendering, routing, auth, rate limiting, caching, and a declarative client-side partial-update library.
4
-
5
- Inspired by [Hono](https://hono.dev), @lengkapp/edge aims for the same class of performance while shipping with **zero runtime dependencies**. See [Performance](#performance) for a local benchmark against Hono, `hono/tiny`, and a plain native Workers handler.
6
-
7
- > **Security:** This release hardens both the server and the client against the OWASP Top 10:2025 and ASVS 5.0 Layer 1 controls. See [Security](#security-controls) for the full breakdown of what's protected and how to opt into the strongest settings.
8
-
9
- ---
10
-
11
- ## Features
12
-
13
- ### Server
14
- - **Trie-based routing** – static & dynamic routes (`/users/:id`) with fast lookups.
15
- - **JSX support** – render JSX components to HTML without a build step (using `jsx` and `renderToString`).
16
- - **Middleware options** – authentication, rate limiting, CORS, logging, caching, compression, and custom validation.
17
- - **Cookie helpers** – set, get, and delete cookies with flexible options.
18
- - **Scheduled tasks** – built-in support for Cron triggers.
19
- - **Zero dependencies** – lightweight and fast.
20
-
21
- ### Client
22
- - **Declarative partial updates** – enhance HTML with `_get`, `_post`, `_target`, `_trigger` attributes to fetch and replace content dynamically.
23
- - **Skeleton loaders & retry UI** – built-in loading and error states.
24
- - **Safe DOM updates** – server responses are parsed with `DOMParser` and inserted via `importNode`; scripts are stripped and never re-executed.
25
- - **CSRF-aware** – state-changing requests automatically send `X-CSRF-Token` from a `<meta name="csrf-token">` tag or an `XSRF-TOKEN` cookie.
26
- - **Trusted Types aware** – plays nicely with a `require-trusted-types-for 'script'` CSP.
27
-
28
- ### Security (both)
29
- - **Prototype-pollution safe** – route params, cookies, JSON payloads, and JSX attributes all use forbidden-key filtering.
30
- - **XSS-hardened JSX** – event handlers (`on*`), malformed tag/attr names, and `javascript:`/`data:text/html` URIs are blocked.
31
- - **Structured security logging** – auth failures, rate-limit hits, and handler errors emit throttled JSON events.
32
- - **Fail-closed middleware** – validation, auth, and rate-limit errors deny by default.
33
- - **Opt-in token hashing** – SHA-256 hash auth tokens before KV lookup.
34
- - **Strict CORS allowlist** – supports per-origin reflection with `Vary: Origin` and credentials.
35
-
36
- ---
37
-
38
- ## Installation
39
-
40
- ```bash
41
- npm install @lengkapp/edge
42
- ```
43
-
44
- ## Quick Start
45
-
46
- **1. Create a worker script** (`worker.js` or `src/index.ts`):
47
-
48
- ```ts
49
- import { Edge, renderToString } from '@lengkapp/edge';
50
-
51
- const app = new Edge();
52
-
53
- app.get('/', (ctx) => ctx.text('Hello World!'));
54
-
55
- app.get('/users/:id', (ctx) => {
56
- return ctx.json({ id: ctx.params.id });
57
- });
58
-
59
- export default {
60
- fetch: (request, env, ctx) => app.fetch(request, env, ctx),
61
- };
62
- ```
63
-
64
- **2. Create `wrangler.jsonc`:**
65
-
66
- > Cloudflare recommends `wrangler.jsonc` for new projects (TOML is still supported — see [Configuration](#configuration)).
67
-
68
- ```jsonc
69
- {
70
- "$schema": "./node_modules/wrangler/config-schema.json",
71
- "name": "my-edge-app",
72
- "main": "worker.js",
73
- // Set this to today's date
74
- "compatibility_date": "2026-09-06",
75
- "observability": {
76
- "enabled": true
77
- }
78
- }
79
- ```
80
-
81
- **3. Deploy:**
82
-
83
- ```bash
84
- wrangler deploy
85
- ```
86
-
87
- > `wrangler publish` was removed in favor of `wrangler deploy`. If you're on an older Wrangler version, run `npm install -g wrangler@latest` first.
88
-
89
- ---
90
-
91
- ## Server API
92
-
93
- ### Creating an App
94
-
95
- ```ts
96
- import { Edge } from '@lengkapp/edge';
97
- const app = new Edge();
98
- ```
99
-
100
- ### Routing
101
-
102
- Register routes using HTTP method helpers. Both static and dynamic paths are supported.
103
-
104
- ```ts
105
- // Static
106
- app.get('/home', handler);
107
- app.post('/submit', handler);
108
-
109
- // Dynamic (colon-prefixed segments)
110
- app.get('/users/:id', (ctx) => {
111
- const userId = ctx.params.id;
112
- });
113
- ```
114
-
115
- Supported methods: `get`, `post`, `put`, `delete`, `patch`, `options`, `head`.
116
-
117
- ### Context Object
118
-
119
- Each handler receives a `Context` object (`ctx`) with the following members:
120
-
121
- | Property / Method | Description |
122
- |---|---|
123
- | `ctx.req` | The original `Request` object |
124
- | `ctx.env` | The environment bindings (KV, secrets, etc.) |
125
- | `ctx.executionCtx` | The `ExecutionContext` (for `waitUntil`) |
126
- | `ctx.params` | Route parameters (prototype-safe object) |
127
- | `ctx.status` | HTTP status code (default 200) |
128
- | `ctx.headers` | `Headers` object for the response |
129
- | `ctx.query` | `URLSearchParams` (lazy) |
130
- | `ctx.getCookie(name)` | Get a cookie value |
131
- | `ctx.setCookie(name, value, options)` | Set a cookie (name is validated) |
132
- | `ctx.deleteCookie(name, options)` | Delete a cookie (expires immediately) |
133
- | `ctx.text(data, status?, headers?)` | Return plain text |
134
- | `ctx.json(data, status?, headers?)` | Return JSON |
135
- | `ctx.html(data, status?, headers?)` | Return HTML |
136
-
137
- Cookie options: `path`, `domain`, `maxAge`, `expires`, `secure`, `httpOnly`, `sameSite`.
138
-
139
- Every response automatically includes these safe headers:
140
-
141
- - `X-Content-Type-Options: nosniff`
142
- - `Referrer-Policy: strict-origin-when-cross-origin`
143
-
144
- You can add more via `app.security.extraHeaders` (see [Security Controls](#security-controls)).
145
-
146
- ### Returning Responses
147
-
148
- You can return a `Response` object, a string (becomes text), or an object (automatically JSON).
149
- If the handler returns a JSX element, it will be rendered to HTML automatically (see [JSX Support](#jsx-support)).
150
-
151
- ```ts
152
- app.get('/json', (ctx) => {
153
- return { hello: 'world' }; // → ctx.json()
154
- });
155
-
156
- app.get('/html', (ctx) => {
157
- return '<h1>Hi</h1>'; // → ctx.html()
158
- });
159
- ```
160
-
161
- ### Route Options
162
-
163
- Pass an options object as the second argument (before the handler) to enable middleware.
164
-
165
- #### Authentication (`auth`)
166
-
167
- Requires an `AUTH_KV` KV binding. Valid tokens are stored as keys with a JSON payload.
168
-
169
- ```ts
170
- app.get('/protected', { auth: true }, handler);
171
- app.get('/admin', { auth: { role: 'admin' } }, handler);
172
- app.get('/scoped', { auth: { scopes: ['read', 'write'] } }, handler);
173
- ```
174
-
175
- The client must send the token via `Authorization: Bearer <token>` or a cookie named `auth_token`.
176
-
177
- If the KV payload includes an `exp` field (Unix seconds), expired tokens are rejected with a `401`. Malformed JSON payloads are rejected with `401` and logged as a security event.
178
-
179
- #### Rate Limiting (`rateLimit`)
180
-
181
- Requires a `RATE_LIMIT_KV` binding. Rate-limit responses include a `Retry-After: 60` header.
182
-
183
- ```ts
184
- app.get('/limited', { rateLimit: { max: 100, window: 60 } }, handler);
185
- ```
186
-
187
- #### Caching (`cache`)
188
-
189
- Caches successful GET responses in the Cloudflare cache.
190
-
191
- ```ts
192
- app.get('/cached', { cache: { ttl: 60, staleWhileRevalidate: 30 } }, handler);
193
- ```
194
-
195
- #### CORS (`cors`)
196
-
197
- Enables CORS headers. The default (`origin: '*'`) preserves legacy behavior. For production, use a strict allowlist:
198
-
199
- ```ts
200
- // Legacy wildcard (default)
201
- app.get('/api', { cors: true }, handler);
202
-
203
- // Single origin
204
- app.get('/api', { cors: { origin: 'https://example.com', methods: 'GET,POST' } }, handler);
205
-
206
- // Strict allowlist — reflects matching Origin, sets Vary: Origin and credentials
207
- app.get('/api', {
208
- cors: {
209
- origin: ['https://app.example.com', 'https://admin.example.com'],
210
- methods: 'GET,POST,PUT,DELETE',
211
- headers: 'Content-Type, Authorization, X-CSRF-Token',
212
- },
213
- }, handler);
214
- ```
215
-
216
- #### Logging (`log`)
217
-
218
- Logs method, URL, and status to the console. Separately, security events (auth failures, rate-limit hits, handler errors) are logged as structured JSON when `app.security.logSecurityEvents` is `true` (the default). Events are deduplicated per `(event, IP, path)` over a 60-second window to prevent log flooding.
219
-
220
- #### Compression (`compress`)
221
-
222
- Compresses the response using gzip/deflate if the client supports it.
223
-
224
- #### Custom Validation (`validate`)
225
-
226
- A function returning `true`/`false` (or a `Promise`) to allow/deny the request. Validation errors fail closed.
227
-
228
- ```ts
229
- app.post('/submit', {
230
- validate: (ctx) => ctx.req.headers.get('Authorization') === 'Bearer secret'
231
- }, handler);
232
- ```
233
-
234
- ---
235
-
236
- ## Security Controls
237
-
238
- All controls are optional and default to behavior that preserves previous releases. Enable them after construction:
239
-
240
- ```ts
241
- const app = new Edge();
242
-
243
- // 1. Strict CORS allowlist (replaces the default '*')
244
- app.defaults.cors.origin = ['https://app.example.com', 'https://admin.example.com'];
245
-
246
- // 2. Hash auth tokens with SHA-256 before KV lookup.
247
- // Only enable after migrating existing tokens to `tok:<sha256-hex>` keys.
248
- app.security.hashAuthTokens = true;
249
-
250
- // 3. Add HSTS, COOP, COEP, CORP at the edge.
251
- app.security.extraHeaders = {
252
- 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
253
- 'Cross-Origin-Opener-Policy': 'same-origin',
254
- 'Cross-Origin-Resource-Policy': 'same-origin',
255
- };
256
-
257
- // 4. Disable structured security logs if you ship logs elsewhere.
258
- // app.security.logSecurityEvents = false;
259
- ```
260
-
261
- ### What's Protected
262
-
263
- | OWASP 2025 Category | Protection in @lengkapp/edge |
264
- |---|---|
265
- | A01: Broken Access Control | Structured auth result with reasons; expiry enforcement when `exp` is present; role/scope checks; strict target resolution on the client. |
266
- | A02: Security Misconfiguration | Safe default headers (`nosniff`, `Referrer-Policy`); strict CORS allowlist support; per-origin reflection with `Vary: Origin`. |
267
- | A03: Software Supply Chain | Zero runtime dependencies; SRI attributes honored on the client for `x-js-required` / `x-css-required`. |
268
- | A04: Cryptographic Failures | Optional SHA-256 token hashing before KV lookup (`app.security.hashAuthTokens`). |
269
- | A05: Injection / XSS / Prototype Pollution | `Object.create(null)` for params, cookies, and JSON; forbidden-key filtering in route segments, JSON payloads, cookie names, and JSX attributes; event-handler attributes blocked; `javascript:`/`data:text/html` URIs rejected; client uses `DOMParser` + `importNode` and never executes inline scripts. |
270
- | A06: Insecure Design | Numeric-sanitized rate limiter; `Retry-After` on 429; sliding counter per IP. |
271
- | A07: Authentication Failures | Specific failure reasons logged; no swallowed catches in the auth path. |
272
- | A08: Data Integrity | Prototype-safe JSON parsing for KV payloads; `Set-Cookie` stripped from cached responses. |
273
- | A09: Logging Failures | Structured JSON security events with 60-second dedupe window. |
274
- | A10: Exceptional Conditions | Validation, auth, and rate-limit middleware fail closed on errors; handler errors never leak internals. |
275
-
276
- ---
277
-
278
- ## JSX Support
279
-
280
- The package includes a minimal JSX runtime. Write components as functions returning JSX.
281
-
282
- ```tsx
283
- import { jsx, renderToString, Fragment } from '@lengkapp/edge';
284
-
285
- function Card({ title }) {
286
- return (
287
- <div class="card">
288
- <h2>{title}</h2>
289
- </div>
290
- );
291
- }
292
-
293
- app.get('/card', (ctx) => {
294
- return ctx.html(renderToString(<Card title="Hello" />));
295
- });
296
- ```
297
-
298
- If your handler returns a JSX element directly, Edge will automatically render it:
299
-
300
- ```tsx
301
- app.get('/auto', (ctx) => <h1>Auto rendered</h1>);
302
- ```
303
-
304
- ### JSX Hardening
305
-
306
- `renderToString` applies the following protections automatically:
307
-
308
- - Tag names must match `^[A-Za-z][A-Za-z0-9-]*$` — anything else is dropped.
309
- - Attribute names must match `^[A-Za-z_:][A-Za-z0-9_:.-]*$` and are rejected if they start with `on` (event handlers never serialize).
310
- - `href`, `src`, `xlink:href`, `action`, and `formaction` values beginning with `javascript:`, `vbscript:`, or `data:text/html` are stripped.
311
- - All string values are HTML-escaped (`&`, `<`, `>`, `"`, `'`).
312
- - Prototype keys (`__proto__`, `constructor`, `prototype`) are rejected as attribute names.
313
- - `dangerouslySetInnerHTML` remains available but is opt-in and never applied to attribute values.
314
-
315
- ---
316
-
317
- ## Scheduled Tasks
318
-
319
- Register a scheduled handler for Cron triggers.
320
-
321
- ```ts
322
- app.scheduled(async (event, env, ctx) => {
323
- console.log('Cron executed:', event.cron);
324
- });
325
- ```
326
-
327
- Then export it in your worker:
328
-
329
- ```ts
330
- export default {
331
- fetch: (req, env, ctx) => app.fetch(req, env, ctx),
332
- scheduled: (event, env, ctx) => app.scheduledHandler?.(event, env, ctx)
333
- };
334
- ```
335
-
336
- ---
337
-
338
- ## Client (Declarative Partial Updates)
339
-
340
- Add the client script to your page (or include it via a CDN):
341
-
342
- ```html
343
- <script src="https://cdn.example.com/edge-client.min.js"></script>
344
- ```
345
-
346
- Then use HTML attributes to make elements fetch content asynchronously.
347
-
348
- ### Attributes
349
-
350
- | Attribute | Description |
351
- |---|---|
352
- | `_get` | URL to fetch via GET |
353
- | `_post` | URL to fetch via POST |
354
- | `_put`, `_patch`, `_delete` | Other HTTP methods |
355
- | `_target` | CSS selector for the container to replace. Use `"this"` to replace the element itself. Only simple selectors (`#id`, `.class`, `tag`) are accepted for safety. |
356
- | `_trigger` | Comma-separated list of events that trigger the fetch (default: `click`; `load` if `_target="this"`). Supported: `click`, `load`, `visible`, `intersect`, `submit`, etc. |
357
- | `_form` | (with `_post`) ID of a form to serialize as the POST body (URL-encoded). |
358
- | `_json` | (with `_post`) Comma-separated names of input fields to send as JSON. |
359
- | `_skeleton` | Set to `"false"` to disable the loading skeleton. |
360
- | `_retry` | Set to `"false"` to disable the retry button on error. |
361
-
362
- ### Examples
363
-
364
- Load content on click:
365
-
366
- ```html
367
- <button _get="/more-posts" _target="#posts" _trigger="click">Load More</button>
368
- <div id="posts"></div>
369
- ```
370
-
371
- Auto-load on page load:
372
-
373
- ```html
374
- <div _get="/user-profile" _target="this"></div>
375
- ```
376
-
377
- Post a form via AJAX:
378
-
379
- ```html
380
- <form id="contact-form">
381
- <input name="email" />
382
- <button _post="/submit" _form="contact-form" _target="#result">Submit</button>
383
- </form>
384
- <div id="result"></div>
385
- ```
386
-
387
- Send JSON data:
388
-
389
- ```html
390
- <input name="username" />
391
- <input name="password" />
392
- <button _post="/login" _json="username,password" _target="#status">Login</button>
393
- ```
394
-
395
- Load when element becomes visible (IntersectionObserver):
396
-
397
- ```html
398
- <div _get="/lazy-content" _target="this" _trigger="visible"></div>
399
- ```
400
-
401
- ### Skeleton Loading
402
-
403
- While the request is in progress, the target container is filled with a skeleton loader (a pulsing ring icon). On error, a "Retry" button is shown; clicking it re-runs the same request once.
404
-
405
- ### Safe DOM Updates
406
-
407
- Server responses are handled as follows:
408
-
409
- - Fetched with `credentials: 'same-origin'` and `X-Requested-With: XMLHttpRequest`.
410
- - `Content-Type` is validated — only `text/html` and `text/plain` are accepted.
411
- - The HTML is parsed in a detached `DOMParser` document.
412
- - All `<script>` elements are removed before insertion.
413
- - Nodes are imported with `document.importNode()` — never `innerHTML`.
414
- - Any new AJAX-enabled elements in the response are bound to the same delegation system.
415
-
416
- ### CSRF Protection
417
-
418
- For state-changing methods (POST, PUT, PATCH, DELETE), the client reads a CSRF token from:
419
-
420
- 1. `<meta name="csrf-token" content="...">` (preferred), or
421
- 2. The `XSRF-TOKEN` cookie.
422
-
423
- The token is sent as `X-CSRF-Token` on the request. Your server handler can verify it:
424
-
425
- ```ts
426
- app.post('/submit', {
427
- validate: (ctx) => {
428
- const sent = ctx.req.headers.get('X-CSRF-Token');
429
- return sent && sent === ctx.getCookie('csrf_token');
430
- }
431
- }, handler);
432
- ```
433
-
434
- ### Resource Injection
435
-
436
- If the server response includes headers `x-css-required` or `x-js-required`, the client will automatically inject those resources (once per URL) into the page. Each URL can optionally include a matching `x-css-integrity` or `x-js-integrity` header containing a Subresource Integrity hash.
437
-
438
- Example server route:
439
-
440
- ```ts
441
- app.get('/widget', (ctx) => {
442
- ctx.headers.set('x-css-required', '["/widget.css"]');
443
- ctx.headers.set('x-js-required', '["/widget.js"]');
444
- // Optional SRI — applies to every URL in the corresponding list
445
- ctx.headers.set('x-js-integrity', 'sha384-...');
446
- return ctx.html('<div class="widget">...</div>');
447
- });
448
- ```
449
-
450
- ### Trusted Types
451
-
452
- If your page enforces `require-trusted-types-for 'script'`, the client detects `window.trustedTypes` and creates an `aj-policy`. Only hardcoded UI strings (skeleton, retry) pass through the policy; server content never does.
453
-
454
- ---
455
-
456
- ## Configuration
457
-
458
- ### wrangler.jsonc
459
-
460
- If you use authentication or rate limiting, you need KV namespaces. Create them first:
461
-
462
- ```bash
463
- wrangler kv namespace create AUTH_KV
464
- wrangler kv namespace create RATE_LIMIT_KV
465
- ```
466
-
467
- Then wire up the returned IDs in your config:
468
-
469
- ```jsonc
470
- {
471
- "$schema": "./node_modules/wrangler/config-schema.json",
472
- "name": "my-edge-app",
473
- "main": "worker.js",
474
- // Set this to today's date
475
- "compatibility_date": "2026-09-06",
476
- "observability": {
477
- "enabled": true
478
- },
479
- "kv_namespaces": [
480
- { "binding": "AUTH_KV", "id": "your-auth-kv-id" },
481
- { "binding": "RATE_LIMIT_KV", "id": "your-ratelimit-kv-id" }
482
- ],
483
- // Optional Cron triggers
484
- "triggers": {
485
- "crons": ["*/5 * * * *"]
486
- }
487
- }
488
- ```
489
-
490
- <details>
491
- <summary>Equivalent <code>wrangler.toml</code></summary>
492
-
493
- ```toml
494
- name = "my-edge-app"
495
- main = "worker.js"
496
- compatibility_date = "2026-09-06"
497
-
498
- [observability]
499
- enabled = true
500
-
501
- [[kv_namespaces]]
502
- binding = "AUTH_KV"
503
- id = "your-auth-kv-id"
504
-
505
- [[kv_namespaces]]
506
- binding = "RATE_LIMIT_KV"
507
- id = "your-ratelimit-kv-id"
508
-
509
- # Optional Cron triggers
510
- [triggers]
511
- crons = ["*/5 * * * *"]
512
- ```
513
-
514
- </details>
515
-
516
- > Wrangler supports both `wrangler.jsonc` and `wrangler.toml` — they configure the same fields, just in different syntax. Don't keep both in the same project. Keep `compatibility_date` current (Cloudflare recommends staying within the last 30 days); see the [compatibility dates docs](https://developers.cloudflare.com/workers/configuration/compatibility-dates/).
517
-
518
- ### Environment Variables
519
-
520
- The KV binding names are configurable on the Edge instance:
521
-
522
- ```ts
523
- app.authKvBinding = 'CUSTOM_AUTH_KV';
524
- app.rateLimitKvBinding = 'CUSTOM_RATE_KV';
525
- ```
526
-
527
- ### Recommended CSP
528
-
529
- The client and JSX runtime are compatible with a strict Content Security Policy. A reasonable starting point:
530
-
531
- ```text
532
- Content-Security-Policy:
533
- default-src 'self';
534
- script-src 'self';
535
- style-src 'self';
536
- img-src 'self' data:;
537
- require-trusted-types-for 'script';
538
- frame-ancestors 'none';
539
- base-uri 'self';
540
- ```
541
-
542
- Set this via `app.security.extraHeaders` or at your edge/CDN layer.
543
-
544
- ---
545
-
546
- ## Full Example
547
-
548
- ```ts
549
- import { Edge, renderToString } from '@lengkapp/edge';
550
- import { HomePage } from './Page.jsx';
551
-
552
- const app = new Edge();
553
-
554
- // Strict CORS allowlist for production
555
- app.defaults.cors.origin = ['https://app.example.com'];
556
-
557
- // HSTS + isolation headers
558
- app.security.extraHeaders = {
559
- 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
560
- 'Cross-Origin-Opener-Policy': 'same-origin',
561
- 'Cross-Origin-Resource-Policy': 'same-origin',
562
- };
563
-
564
- // Basic text
565
- app.get('/', (ctx) => ctx.text('Hello from Edge!'));
566
-
567
- // JSX component
568
- app.get('/home', (ctx) => ctx.html(renderToString(<HomePage />)));
569
-
570
- // JSON with route params
571
- app.get('/users/:id', (ctx) => ctx.json({ userId: ctx.params.id }));
572
-
573
- // POST JSON
574
- app.post('/users', async (ctx) => {
575
- const body = await ctx.req.json();
576
- return ctx.json({ created: true, user: body }, 201);
577
- });
578
-
579
- // Auth
580
- app.get('/protected', { auth: true }, (ctx) => ctx.text('Authenticated'));
581
-
582
- // Rate limiting
583
- app.get('/limited', { rateLimit: { max: 5, window: 60 } }, (ctx) => ctx.text('Limited'));
584
-
585
- // Caching
586
- app.get('/cached', { cache: { ttl: 60 } }, (ctx) => ctx.text('Cached'));
587
-
588
- // Cookies
589
- app.get('/set-cookie', (ctx) => {
590
- ctx.setCookie('session', 'abc123', { httpOnly: true, secure: true, sameSite: 'Lax', path: '/' });
591
- return ctx.text('Cookie set');
592
- });
593
-
594
- app.get('/get-cookie', (ctx) => {
595
- const session = ctx.getCookie('session');
596
- return ctx.text(`Cookie: ${session}`);
597
- });
598
-
599
- // Scheduled
600
- app.scheduled(async (event, env, ctx) => {
601
- console.log('Cron:', event.cron);
602
- });
603
-
604
- export default {
605
- fetch: (req, env, ctx) => app.fetch(req, env, ctx),
606
- scheduled: (event, env, ctx) => app.scheduledHandler?.(event, env, ctx)
607
- };
608
- ```
609
-
610
- ---
611
-
612
- ## Performance
613
-
614
- @lengkapp/edge is inspired by Hono — one of the fastest Workers frameworks around — but ships with zero dependencies. A quick local benchmark shows it holding its own against both Hono builds (`hono` and `hono/tiny`) and a plain native `fetch` handler with no framework at all.
615
-
616
- ### Test setup
617
-
618
- - **Tool:** autocannon — 10 connections, 10s run per route
619
- - **Target:** local `wrangler dev` server
620
- - **Routes:** `GET /text` (plain text) and `GET /json` (JSON)
621
- - **Contenders:** @lengkapp/edge, hono, hono/tiny, and a native Workers handler with no framework
622
-
623
- > This is a single local run, not a formal benchmark suite — treat the numbers as directional. Latency stdev was ±3–4 ms across all four, so differences smaller than that are within noise.
624
-
625
- ### Results
626
-
627
- | Framework | Route | Avg Req/sec | Avg Latency | Requests | Data Read |
628
- |---|---|---|---|---|---|
629
- | @lengkapp/edge | `/text` | 503.1 | 19.38 ms | 5,000 / 10.03s | 1.38 MB |
630
- | @lengkapp/edge | `/json` | 500.4 | 19.47 ms | 5,000 / 10.03s | 1.42 MB |
631
- | Hono | `/text` | 506.0 | 19.27 ms | 5,000 / 10.03s | 455 kB |
632
- | Hono | `/json` | 495.2 | 19.69 ms | 5,000 / 10.03s | 426 kB |
633
- | Hono (`hono/tiny`) | `/text` | 505.5 | 19.28 ms | 5,000 / 10.03s | 455 kB |
634
- | Hono (`hono/tiny`) | `/json` | 497.9 | 19.57 ms | 5,000 / 10.03s | 428 kB |
635
- | Native Workers (no framework) | `/text` | 498.4 | 19.55 ms | 5,000 / 10.02s | 379 kB |
636
- | Native Workers (no framework) | `/json` | 499.7 | 19.51 ms | 5,000 / 10.03s | 430 kB |
637
-
638
- ### Takeaways
639
-
640
- - On `/json`, @lengkapp/edge posted the highest average throughput of the four, ahead of both Hono builds and the native handler.
641
- - On `/text`, it landed within ~1% of `hono`/`hono/tiny` and ahead of the native baseline — effectively a tie once you account for run-to-run variance.
642
- - All four sit in the same performance tier; the practical difference is that @lengkapp/edge gets there with zero runtime dependencies, matching `hono/tiny`'s footprint without having to choose a "tiny" build.
643
- - @lengkapp/edge read more bytes per request than the other three in this run — worth profiling if minimal payload size matters for your use case.
644
- - The 2025 security hardening adds only cheap operations to the request path: prototype-safe objects (`Object.create(null)`), two regex checks per JSX attribute, and a throttled logger that skips duplicate events within 60 seconds. No new async boundaries, no hashing on the hot path (`hashAuthTokens` defaults to `false`), and no added dependencies.
645
-
646
- ---
647
-
648
- ## Migration Notes
649
-
650
- ### Upgrading from ≤ 0.x
651
-
652
- The security release is backward-compatible. Existing apps continue to work without code changes. New defaults:
653
-
654
- - Every response now includes `X-Content-Type-Options: nosniff` and `Referrer-Policy: strict-origin-when-cross-origin`.
655
- - Rate-limited responses include `Retry-After: 60`.
656
- - Route params, cookie stores, and JSON payloads use prototype-safe objects. If you were relying on `ctx.params.hasOwnProperty(...)`, use `Object.prototype.hasOwnProperty.call(ctx.params, ...)` or `'x' in ctx.params`.
657
- - The JSX renderer now drops `on*` attributes. Handlers relying on server-rendered inline event handlers must be moved to the client.
658
- - Security events are logged as JSON. To disable: `app.security.logSecurityEvents = false`.
659
-
660
- ### Client upgrade
661
-
662
- If you were using the old client that executed `<script>` tags in responses, replace those scripts with either:
663
-
664
- - DOM-based initialization (event delegation, `IntersectionObserver`, etc.), or
665
- - An explicit `<script src="...">` injected via `x-js-required` on the response.
666
-
667
- The new client refuses to run inline scripts for security reasons and will not execute `<script>` elements found in response bodies.
668
-
669
- ---
670
-
671
- ## License
672
-
1
+ # @lengkapp/edge
2
+
3
+ A minimal, high-performance framework for Cloudflare Workers with built-in server-side rendering, routing, auth, rate limiting, caching, and a declarative client-side partial-update library.
4
+
5
+ Inspired by [Hono](https://hono.dev), @lengkapp/edge aims for the same class of performance while shipping with **zero runtime dependencies**. See [Performance](#performance) for a local benchmark against Hono, `hono/tiny`, and a plain native Workers handler.
6
+
7
+ ---
8
+
9
+ ## Features
10
+
11
+ - **Trie-based routing** – static & dynamic routes (`/users/:id`) with fast lookups.
12
+ - **JSX support** – render JSX components to HTML without a build step (using `jsx` and `renderToString`).
13
+ - **Middleware options** – authentication, rate limiting, CORS, logging, caching, compression, and custom validation.
14
+ - **Cookie helpers** – set, get, and delete cookies with flexible options.
15
+ - **Declarative client** – enhance HTML with `_get`, `_post`, `_target`, `_trigger` attributes to fetch and replace content dynamically, with skeleton loaders and retry buttons.
16
+ - **Resource injection** – automatically load CSS/JS files from response headers.
17
+ - **Scheduled tasks** – built-in support for Cron triggers.
18
+ - **Zero dependencies** – lightweight and fast.
19
+
20
+ ---
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ npm install @lengkapp/edge
26
+ ```
27
+
28
+ ---
29
+
30
+ ## Quick Start
31
+
32
+ **1. Create a worker script (`worker.js` or `src/index.ts`):**
33
+
34
+ ```ts
35
+ import { Edge, renderToString } from '@lengkapp/edge';
36
+
37
+ const app = new Edge();
38
+
39
+ app.get('/', (ctx) => ctx.text('Hello World!'));
40
+
41
+ app.get('/users/:id', (ctx) => {
42
+ return ctx.json({ id: ctx.params.id });
43
+ });
44
+
45
+ export default {
46
+ fetch: (request, env, ctx) => app.fetch(request, env, ctx),
47
+ };
48
+ ```
49
+
50
+ **2. Create `wrangler.jsonc`:**
51
+
52
+ Cloudflare recommends `wrangler.jsonc` for new projects (TOML is still supported — see [Configuration](#configuration)).
53
+
54
+ ```jsonc
55
+ {
56
+ "$schema": "./node_modules/wrangler/config-schema.json",
57
+ "name": "my-edge-app",
58
+ "main": "worker.js",
59
+ // Set this to today's date
60
+ "compatibility_date": "2026-09-06",
61
+ "observability": {
62
+ "enabled": true
63
+ }
64
+ }
65
+ ```
66
+
67
+ **3. Deploy:**
68
+
69
+ ```bash
70
+ wrangler deploy
71
+ ```
72
+
73
+ > `wrangler publish` was removed in favor of `wrangler deploy`. If you're on an older Wrangler version, run `npm install -g wrangler@latest` first.
74
+
75
+ ---
76
+
77
+ ## Server API
78
+
79
+ ### Creating an App
80
+
81
+ ```ts
82
+ import { Edge } from '@lengkapp/edge';
83
+ const app = new Edge();
84
+ ```
85
+
86
+ ### Routing
87
+
88
+ Register routes using HTTP method helpers. Both static and dynamic paths are supported.
89
+
90
+ ```ts
91
+ // Static
92
+ app.get('/home', handler);
93
+ app.post('/submit', handler);
94
+
95
+ // Dynamic (colon-prefixed segments)
96
+ app.get('/users/:id', (ctx) => {
97
+ const userId = ctx.params.id;
98
+ });
99
+ ```
100
+
101
+ Supported methods: `get`, `post`, `put`, `delete`, `patch`, `options`, `head`.
102
+
103
+ ### Context Object
104
+
105
+ Each handler receives a `Context` object (`ctx`) with the following members:
106
+
107
+ | Property / Method | Description |
108
+ | --- | --- |
109
+ | `ctx.req` | The original `Request` object |
110
+ | `ctx.env` | The environment bindings (KV, secrets, etc.) |
111
+ | `ctx.executionCtx` | The `ExecutionContext` (for `waitUntil`) |
112
+ | `ctx.params` | Route parameters (e.g., `{ id: '123' }`) |
113
+ | `ctx.status` | HTTP status code (default `200`) |
114
+ | `ctx.headers` | `Headers` object for the response |
115
+ | `ctx.query` | `URLSearchParams` (lazy) |
116
+ | `ctx.getCookie(name)` | Get a cookie value |
117
+ | `ctx.setCookie(name, value, options)` | Set a cookie |
118
+ | `ctx.deleteCookie(name, options)` | Delete a cookie (expires immediately) |
119
+ | `ctx.text(data, status?, headers?)` | Return plain text |
120
+ | `ctx.json(data, status?, headers?)` | Return JSON |
121
+ | `ctx.html(data, status?, headers?)` | Return HTML |
122
+
123
+ Cookie options: `path`, `domain`, `maxAge`, `expires`, `secure`, `httpOnly`, `sameSite`.
124
+
125
+ ### Returning Responses
126
+
127
+ You can return a `Response` object, a string (becomes text), or an object (automatically JSON).
128
+ If the handler returns a JSX element, it will be rendered to HTML automatically (see [JSX Support](#jsx-support)).
129
+
130
+ ```ts
131
+ app.get('/json', (ctx) => {
132
+ return { hello: 'world' }; // → ctx.json()
133
+ });
134
+
135
+ app.get('/html', (ctx) => {
136
+ return '<h1>Hi</h1>'; // → ctx.html()
137
+ });
138
+ ```
139
+
140
+ ### Route Options
141
+
142
+ Pass an options object as the second argument (before the handler) to enable middleware.
143
+
144
+ #### Authentication (`auth`)
145
+
146
+ Requires an `AUTH_KV` KV binding. Valid tokens are stored as keys with a JSON payload.
147
+
148
+ ```ts
149
+ app.get('/protected', { auth: true }, handler);
150
+ app.get('/admin', { auth: { role: 'admin' } }, handler);
151
+ app.get('/scoped', { auth: { scopes: ['read', 'write'] } }, handler);
152
+ ```
153
+
154
+ The client must send the token via `Authorization: Bearer <token>` or a cookie named `auth_token`.
155
+
156
+ #### Rate Limiting (`rateLimit`)
157
+
158
+ Requires a `RATE_LIMIT_KV` binding.
159
+
160
+ ```ts
161
+ app.get('/limited', { rateLimit: { max: 100, window: 60 } }, handler);
162
+ ```
163
+
164
+ #### Caching (`cache`)
165
+
166
+ Caches successful GET responses in the Cloudflare cache.
167
+
168
+ ```ts
169
+ app.get('/cached', { cache: { ttl: 60, staleWhileRevalidate: 30 } }, handler);
170
+ ```
171
+
172
+ #### CORS (`cors`)
173
+
174
+ Enables CORS headers.
175
+
176
+ ```ts
177
+ app.get('/api', { cors: true }, handler);
178
+
179
+ // Custom origin / methods
180
+ app.get('/api', { cors: { origin: 'https://example.com', methods: 'GET,POST' } }, handler);
181
+ ```
182
+
183
+ #### Logging (`log`)
184
+
185
+ Logs method, URL, and status to the console.
186
+
187
+ #### Compression (`compress`)
188
+
189
+ Compresses the response using gzip/deflate/br if the client supports it.
190
+
191
+ #### Custom Validation (`validate`)
192
+
193
+ A function returning `true`/`false` (or a `Promise`) to allow/deny the request.
194
+
195
+ ```ts
196
+ app.post('/submit', {
197
+ validate: (ctx) => ctx.req.headers.get('Authorization') === 'Bearer secret'
198
+ }, handler);
199
+ ```
200
+
201
+ ---
202
+
203
+ ## JSX Support
204
+
205
+ The package includes a minimal JSX runtime. Write components as functions returning JSX.
206
+
207
+ ```tsx
208
+ import { jsx, renderToString, Fragment } from '@lengkapp/edge';
209
+
210
+ function Card({ title }) {
211
+ return (
212
+ <div class="card">
213
+ <h2>{title}</h2>
214
+ </div>
215
+ );
216
+ }
217
+
218
+ app.get('/card', (ctx) => {
219
+ return ctx.html(renderToString(<Card title="Hello" />));
220
+ });
221
+ ```
222
+
223
+ If your handler returns a JSX element directly, Edge will automatically render it:
224
+
225
+ ```tsx
226
+ app.get('/auto', (ctx) => <h1>Auto rendered</h1>);
227
+ ```
228
+
229
+ ---
230
+
231
+ ## Scheduled Tasks
232
+
233
+ Register a scheduled handler for Cron triggers.
234
+
235
+ ```ts
236
+ app.scheduled(async (event, env, ctx) => {
237
+ console.log('Cron executed:', event.cron);
238
+ });
239
+ ```
240
+
241
+ Then export it in your worker:
242
+
243
+ ```ts
244
+ export default {
245
+ fetch: (req, env, ctx) => app.fetch(req, env, ctx),
246
+ scheduled: (event, env, ctx) => app.scheduledHandler?.(event, env, ctx)
247
+ };
248
+ ```
249
+
250
+ ---
251
+
252
+ ## Client (Declarative Partial Updates)
253
+
254
+ Add the client script to your page (or include it via a CDN):
255
+
256
+ ```html
257
+ <script src="https://cdn.example.com/edge-client.min.js"></script>
258
+ ```
259
+
260
+ Then use HTML attributes to make elements fetch content asynchronously.
261
+
262
+ ### Attributes
263
+
264
+ | Attribute | Description |
265
+ | --- | --- |
266
+ | `_get` | URL to fetch via GET |
267
+ | `_post` | URL to fetch via POST |
268
+ | `_target` | CSS selector for the container to replace. Use `"this"` to replace the element itself. |
269
+ | `_trigger` | Comma-separated list of events that trigger the fetch (default: `click`; `load` if `_target="this"`). Supported: `click`, `load`, `visible`, `intersect`, `submit`, etc. |
270
+ | `_form` | (with `_post`) ID of a form to serialize as the POST body (URL-encoded). |
271
+ | `_json` | (with `_post`) Comma-separated names of input fields to send as JSON. |
272
+
273
+ ### Examples
274
+
275
+ **Load content on click:**
276
+
277
+ ```html
278
+ <button _get="/more-posts" _target="#posts" _trigger="click">Load More</button>
279
+ <div id="posts"></div>
280
+ ```
281
+
282
+ **Auto-load on page load:**
283
+
284
+ ```html
285
+ <div _get="/user-profile" _target="this"></div>
286
+ ```
287
+
288
+ **Post a form via AJAX:**
289
+
290
+ ```html
291
+ <form id="contact-form">
292
+ <input name="email" />
293
+ <button _post="/submit" _form="contact-form" _target="#result">Submit</button>
294
+ </form>
295
+ <div id="result"></div>
296
+ ```
297
+
298
+ **Send JSON data:**
299
+
300
+ ```html
301
+ <input name="username" />
302
+ <input name="password" />
303
+ <button _post="/login" _json="username,password" _target="#status">Login</button>
304
+ ```
305
+
306
+ **Load when element becomes visible (IntersectionObserver):**
307
+
308
+ ```html
309
+ <div _get="/lazy-content" _target="this" _trigger="visible"></div>
310
+ ```
311
+
312
+ ### Skeleton Loading
313
+
314
+ While the request is in progress, the target container is filled with a skeleton loader (three shimmer bars). On error, a "Retry" button is shown.
315
+
316
+ ### Resource Injection
317
+
318
+ If the server response includes headers `x-css-required` or `x-js-required`, the client will automatically inject those resources (once per URL) into the page.
319
+
320
+ Example server route:
321
+
322
+ ```ts
323
+ app.get('/widget', (ctx) => {
324
+ ctx.headers.set('x-css-required', '["/widget.css"]');
325
+ ctx.headers.set('x-js-required', '["/widget.js"]');
326
+ return ctx.html('<div class="widget">...</div>');
327
+ });
328
+ ```
329
+
330
+ ---
331
+
332
+ ## Configuration
333
+
334
+ ### `wrangler.jsonc`
335
+
336
+ If you use authentication or rate limiting, you need KV namespaces. Create them first:
337
+
338
+ ```bash
339
+ wrangler kv namespace create AUTH_KV
340
+ wrangler kv namespace create RATE_LIMIT_KV
341
+ ```
342
+
343
+ Then wire up the returned IDs in your config:
344
+
345
+ ```jsonc
346
+ {
347
+ "$schema": "./node_modules/wrangler/config-schema.json",
348
+ "name": "my-edge-app",
349
+ "main": "worker.js",
350
+ // Set this to today's date
351
+ "compatibility_date": "2026-09-06",
352
+ "observability": {
353
+ "enabled": true
354
+ },
355
+ "kv_namespaces": [
356
+ { "binding": "AUTH_KV", "id": "your-auth-kv-id" },
357
+ { "binding": "RATE_LIMIT_KV", "id": "your-ratelimit-kv-id" }
358
+ ],
359
+ // Optional Cron triggers
360
+ "triggers": {
361
+ "crons": ["*/5 * * * *"]
362
+ }
363
+ }
364
+ ```
365
+
366
+ <details>
367
+ <summary>Equivalent <code>wrangler.toml</code></summary>
368
+
369
+ ```toml
370
+ name = "my-edge-app"
371
+ main = "worker.js"
372
+ compatibility_date = "2026-09-06"
373
+
374
+ [observability]
375
+ enabled = true
376
+
377
+ [[kv_namespaces]]
378
+ binding = "AUTH_KV"
379
+ id = "your-auth-kv-id"
380
+
381
+ [[kv_namespaces]]
382
+ binding = "RATE_LIMIT_KV"
383
+ id = "your-ratelimit-kv-id"
384
+
385
+ # Optional Cron triggers
386
+ [triggers]
387
+ crons = ["*/5 * * * *"]
388
+ ```
389
+
390
+ </details>
391
+
392
+ Wrangler supports both `wrangler.jsonc` and `wrangler.toml` — they configure the same fields, just in different syntax. Don't keep both in the same project. Keep `compatibility_date` current (Cloudflare recommends staying within the last 30 days); see the [compatibility dates docs](https://developers.cloudflare.com/workers/configuration/compatibility-dates/).
393
+
394
+ ### Environment Variables
395
+
396
+ The KV binding names are configurable on the Edge instance:
397
+
398
+ ```ts
399
+ app.authKvBinding = 'CUSTOM_AUTH_KV';
400
+ app.rateLimitKvBinding = 'CUSTOM_RATE_KV';
401
+ ```
402
+
403
+ ---
404
+
405
+ ## Full Example
406
+
407
+ ```ts
408
+ import { Edge, renderToString } from '@lengkapp/edge';
409
+ import { HomePage } from './Page.jsx';
410
+
411
+ const app = new Edge();
412
+
413
+ // Basic text
414
+ app.get('/', (ctx) => ctx.text('Hello from Edge!'));
415
+
416
+ // JSX component
417
+ app.get('/home', (ctx) => ctx.html(renderToString(<HomePage />)));
418
+
419
+ // JSON with route params
420
+ app.get('/users/:id', (ctx) => ctx.json({ userId: ctx.params.id }));
421
+
422
+ // POST JSON
423
+ app.post('/users', async (ctx) => {
424
+ const body = await ctx.req.json();
425
+ return ctx.json({ created: true, user: body }, 201);
426
+ });
427
+
428
+ // Auth
429
+ app.get('/protected', { auth: true }, (ctx) => ctx.text('Authenticated'));
430
+
431
+ // Rate limiting
432
+ app.get('/limited', { rateLimit: { max: 5, window: 60 } }, (ctx) => ctx.text('Limited'));
433
+
434
+ // Caching
435
+ app.get('/cached', { cache: { ttl: 60 } }, (ctx) => ctx.text('Cached'));
436
+
437
+ // Cookies
438
+ app.get('/set-cookie', (ctx) => {
439
+ ctx.setCookie('session', 'abc123', { httpOnly: true, path: '/' });
440
+ return ctx.text('Cookie set');
441
+ });
442
+
443
+ app.get('/get-cookie', (ctx) => {
444
+ const session = ctx.getCookie('session');
445
+ return ctx.text(`Cookie: ${session}`);
446
+ });
447
+
448
+ // Scheduled
449
+ app.scheduled(async (event, env, ctx) => {
450
+ console.log('Cron:', event.cron);
451
+ });
452
+
453
+ export default {
454
+ fetch: (req, env, ctx) => app.fetch(req, env, ctx),
455
+ scheduled: (event, env, ctx) => app.scheduledHandler?.(event, env, ctx)
456
+ };
457
+ ```
458
+
459
+ ---
460
+
461
+ ## Performance
462
+
463
+ @lengkapp/edge is inspired by [Hono](https://hono.dev) — one of the fastest Workers frameworks around — but ships with **zero dependencies**. A quick local benchmark shows it holding its own against both Hono builds (`hono` and `hono/tiny`) and a plain native `fetch` handler with no framework at all.
464
+
465
+ ### Test setup
466
+
467
+ - **Tool:** `autocannon` — 10 connections, 10s run per route
468
+ - **Target:** local `wrangler dev` server
469
+ - **Routes:** `GET /text` (plain text) and `GET /json` (JSON)
470
+ - **Contenders:** `@lengkapp/edge`, `hono`, `hono/tiny`, and a native Workers handler with no framework
471
+
472
+ > This is a single local run, not a formal benchmark suite — treat the numbers as directional. Latency stdev was ±3–4 ms across all four, so differences smaller than that are within noise.
473
+
474
+ ### Results
475
+
476
+ | Framework | Route | Avg Req/sec | Avg Latency | Requests | Data Read |
477
+ | --- | --- | --- | --- | --- | --- |
478
+ | **@lengkapp/edge** | `/text` | 503.1 | 19.38 ms | 5,000 / 10.03s | 1.38 MB |
479
+ | **@lengkapp/edge** | `/json` | **500.4** | 19.47 ms | 5,000 / 10.03s | 1.42 MB |
480
+ | Hono | `/text` | **506.0** | 19.27 ms | 5,000 / 10.03s | 455 kB |
481
+ | Hono | `/json` | 495.2 | 19.69 ms | 5,000 / 10.03s | 426 kB |
482
+ | Hono (`hono/tiny`) | `/text` | 505.5 | 19.28 ms | 5,000 / 10.03s | 455 kB |
483
+ | Hono (`hono/tiny`) | `/json` | 497.9 | 19.57 ms | 5,000 / 10.03s | 428 kB |
484
+ | Native Workers (no framework) | `/text` | 498.4 | 19.55 ms | 5,000 / 10.02s | 379 kB |
485
+ | Native Workers (no framework) | `/json` | 499.7 | 19.51 ms | 5,000 / 10.03s | 430 kB |
486
+
487
+ ```mermaid
488
+ xychart-beta
489
+ title "Avg requests/sec — GET /text"
490
+ x-axis ["@lengkapp/edge", "Hono", "Hono (tiny)", "Native Workers"]
491
+ y-axis "Req/sec" 490 --> 510
492
+ bar [503.1, 506.0, 505.5, 498.4]
493
+ ```
494
+
495
+ ```mermaid
496
+ xychart-beta
497
+ title "Avg requests/sec — GET /json"
498
+ x-axis ["@lengkapp/edge", "Hono", "Hono (tiny)", "Native Workers"]
499
+ y-axis "Req/sec" 490 --> 505
500
+ bar [500.4, 495.2, 497.9, 499.7]
501
+ ```
502
+
503
+ ### Takeaways
504
+
505
+ - On `/json`, @lengkapp/edge posted the highest average throughput of the four, ahead of both Hono builds and the native handler.
506
+ - On `/text`, it landed within ~1% of `hono`/`hono/tiny` and ahead of the native baseline — effectively a tie once you account for run-to-run variance.
507
+ - All four sit in the same performance tier; the practical difference is that @lengkapp/edge gets there with **zero runtime dependencies**, matching `hono/tiny`'s footprint without having to choose a "tiny" build.
508
+ - @lengkapp/edge read more bytes per request than the other three in this run — worth profiling if minimal payload size matters for your use case.
509
+
510
+ ---
511
+
512
+ ## License
513
+
673
514
  See [LICENSE](./LICENSE). © LengkApp — Yasir Haris