@lengkapp/edge 0.0.17 → 0.0.19

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.
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * @lengkapp/edge v0.0.17
2
+ * @lengkapp/edge v0.0.19
3
3
  * MIT License
4
4
  *
5
5
  * LengkApp Edge License
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * @lengkapp/edge v0.0.17
2
+ * @lengkapp/edge v0.0.19
3
3
  * MIT License
4
4
  *
5
5
  * LengkApp Edge License
@@ -30,112 +30,151 @@
30
30
  * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
31
31
  * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
32
32
  */
33
- /// <reference types="@cloudflare/workers-types" />
33
+ // edge-server.d.ts
34
34
 
35
- declare class Context {
35
+ export const Fragment: unique symbol;
36
+
37
+ export interface JSXNode {
38
+ type: unknown;
39
+ props: Record<string, any>;
40
+ children: any[];
41
+ __isJSX: true;
42
+ }
43
+
44
+ export function jsx(type: unknown, props: any, ...children: any[]): JSXNode;
45
+ export function renderToString(node: any): string;
46
+
47
+ /* ---------------- Context ---------------- */
48
+
49
+ export interface CookieOptions {
50
+ path?: string;
51
+ domain?: string;
52
+ maxAge?: number;
53
+ expires?: Date;
54
+ secure?: boolean;
55
+ httpOnly?: boolean;
56
+ sameSite?: 'Strict' | 'Lax' | 'None';
57
+ }
58
+
59
+ export interface RouteOptions {
60
+ auth?: boolean | {
61
+ role?: string;
62
+ scopes?: string[];
63
+ };
64
+ rateLimit?: boolean | {
65
+ /** max requests per window (default 100) */
66
+ max?: number;
67
+ /** window in seconds (default 60) */
68
+ window?: number;
69
+ };
70
+ cors?: boolean | {
71
+ origin?: string | string[];
72
+ methods?: string;
73
+ headers?: string;
74
+ };
75
+ cache?: boolean | {
76
+ /** seconds (default 3600) */
77
+ ttl?: number;
78
+ staleWhileRevalidate?: number;
79
+ };
80
+ compress?: boolean;
81
+ log?: boolean;
82
+ validate?: (ctx: Context) => boolean | Promise<boolean>;
83
+ }
84
+
85
+ export type Handler<E = any> = (ctx: Context<E>) => any | Promise<any>;
86
+
87
+ export class Context<E = any> {
36
88
  constructor(
37
89
  request: Request,
38
- env: any,
90
+ env: E,
39
91
  executionCtx: ExecutionContext,
40
92
  params?: Record<string, string>,
41
- parsedUrl?: URL | null
93
+ parsedUrl?: URL | null,
42
94
  );
43
95
 
44
96
  req: Request;
45
- env: any;
97
+ env: E;
46
98
  executionCtx: ExecutionContext;
47
99
  params: Record<string, string>;
48
100
  status: number;
49
101
  headers: Headers;
50
102
  url: URL;
51
103
 
52
- private _rawCookie: string;
53
- private _cookies: Record<string, string> | null;
104
+ readonly query: URLSearchParams;
54
105
 
55
106
  getCookie(name: string): string | null;
56
- readonly query: URLSearchParams;
57
- setCookie(name: string, value: string, options?: {
58
- path?: string; domain?: string; maxAge?: number; expires?: Date;
59
- secure?: boolean; httpOnly?: boolean; sameSite?: 'Strict' | 'Lax' | 'None';
60
- }): void;
61
- deleteCookie(name: string, options?: {
62
- path?: string; domain?: string; secure?: boolean; httpOnly?: boolean;
63
- sameSite?: 'Strict' | 'Lax' | 'None';
64
- }): void;
107
+ setCookie(name: string, value: string, options?: CookieOptions): void;
108
+ deleteCookie(name: string, options?: CookieOptions): void;
65
109
 
66
110
  text(data: string, status?: number, headers?: Record<string, string>): Response;
67
111
  json(data: any, status?: number, headers?: Record<string, string>): Response;
68
- html(data: string, status?: number, headers?: Record<string, string>): Response;
69
112
 
70
- private _buildHeaders(headers: Record<string, string>): Headers;
113
+ /** Accepts a raw HTML string, JSX, or an array of JSX. */
114
+ html(
115
+ data: string | JSXNode | JSXNode[] | null | undefined,
116
+ status?: number,
117
+ headers?: Record<string, string>,
118
+ ): Response;
71
119
  }
72
120
 
73
- export const Fragment: unique symbol;
121
+ /* ---------------- Edge ---------------- */
74
122
 
75
- export interface JSXElement {
76
- type: any;
77
- props: Record<string, any>;
78
- children: any[];
79
- __isJSX: true;
123
+ export interface SecurityConfig {
124
+ hashAuthTokens: boolean;
125
+ logSecurityEvents: boolean;
126
+ extraHeaders: Record<string, string> | null;
127
+ trustedProxies: string[] | null;
80
128
  }
81
129
 
82
- export function jsx(type: any, props: Record<string, any> | null, ...children: any[]): JSXElement;
83
- export function renderToString(node: any): string;
84
-
85
- interface AuthOptions { role?: string; scopes?: string[]; }
86
- interface RateLimitOptions { max?: number; window?: number; }
87
- interface CorsOptions { origin?: string | string[]; methods?: string; headers?: string; }
88
- interface CacheOptions { ttl?: number; staleWhileRevalidate?: number; }
89
-
90
- interface RouteOptions {
91
- auth?: boolean | AuthOptions;
92
- rateLimit?: boolean | RateLimitOptions;
93
- cors?: boolean | CorsOptions;
94
- validate?: (ctx: Context) => boolean | Promise<boolean>;
95
- cache?: boolean | CacheOptions;
96
- compress?: boolean;
97
- log?: boolean;
98
- }
99
-
100
- interface SecurityOptions {
101
- hashAuthTokens?: boolean;
102
- logSecurityEvents?: boolean;
103
- extraHeaders?: Record<string, string> | null;
104
- trustedProxies?: string[] | null;
130
+ export interface Defaults {
131
+ cors: { origin: string; methods: string };
105
132
  }
106
133
 
107
- type RouteHandler = (ctx: Context) => Response | JSXElement | void | Promise<Response | JSXElement | void>;
108
- type ScheduledHandler = (controller: ScheduledController, env: any, ctx: ExecutionContext) => void | Promise<void>;
109
-
110
- export class Edge {
134
+ export class Edge<E = any> {
111
135
  constructor();
136
+
112
137
  authKvBinding: string;
113
138
  rateLimitKvBinding: string;
114
- defaults: { cors: CorsOptions };
115
- scheduledHandler: ScheduledHandler | null;
116
- security: Required<SecurityOptions>;
117
-
118
- get(path: string, handler: RouteHandler): void;
119
- get(path: string, options: boolean | RouteOptions, handler: RouteHandler): void;
120
- post(path: string, handler: RouteHandler): void;
121
- post(path: string, options: boolean | RouteOptions, handler: RouteHandler): void;
122
- put(path: string, handler: RouteHandler): void;
123
- put(path: string, options: boolean | RouteOptions, handler: RouteHandler): void;
124
- delete(path: string, handler: RouteHandler): void;
125
- delete(path: string, options: boolean | RouteOptions, handler: RouteHandler): void;
126
- patch(path: string, handler: RouteHandler): void;
127
- patch(path: string, options: boolean | RouteOptions, handler: RouteHandler): void;
128
- options(path: string, handler: RouteHandler): void;
129
- options(path: string, options: boolean | RouteOptions, handler: RouteHandler): void;
130
- head(path: string, handler: RouteHandler): void;
131
- head(path: string, options: boolean | RouteOptions, handler: RouteHandler): void;
132
- scheduled(handler: ScheduledHandler): void;
133
- fetch(request: Request, env: any, executionCtx: ExecutionContext): Promise<Response>;
139
+ defaults: Defaults;
140
+ security: SecurityConfig;
141
+
142
+ get(path: string, options?: RouteOptions | boolean, handler?: Handler<E>): void;
143
+ get(path: string, handler: Handler<E>): void;
144
+
145
+ post(path: string, options?: RouteOptions | boolean, handler?: Handler<E>): void;
146
+ post(path: string, handler: Handler<E>): void;
147
+
148
+ put(path: string, options?: RouteOptions | boolean, handler?: Handler<E>): void;
149
+ put(path: string, handler: Handler<E>): void;
150
+
151
+ delete(path: string, options?: RouteOptions | boolean, handler?: Handler<E>): void;
152
+ delete(path: string, handler: Handler<E>): void;
153
+
154
+ patch(path: string, options?: RouteOptions | boolean, handler?: Handler<E>): void;
155
+ patch(path: string, handler: Handler<E>): void;
156
+
157
+ options(path: string, options?: RouteOptions | boolean, handler?: Handler<E>): void;
158
+ options(path: string, handler: Handler<E>): void;
159
+
160
+ head(path: string, options?: RouteOptions | boolean, handler?: Handler<E>): void;
161
+ head(path: string, handler: Handler<E>): void;
162
+
163
+ scheduled(
164
+ handler: (event: ScheduledEvent, env: E, ctx: ExecutionContext) => any | Promise<any>,
165
+ ): void;
166
+
167
+ fetch(request: Request, env: E, executionCtx: ExecutionContext): Promise<Response>;
134
168
  }
135
169
 
136
- declare global {
137
- namespace JSX {
138
- interface Element extends JSXElement {}
139
- interface IntrinsicElements { [elemName: string]: any; }
170
+ /* ---------------- JSX typing for this runtime ---------------- */
171
+
172
+ export namespace JSX {
173
+ type Element = JSXNode;
174
+ interface IntrinsicElements {
175
+ [elemName: string]: any;
176
+ }
177
+ interface ElementChildrenAttribute {
178
+ children: {};
140
179
  }
141
180
  }
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * @lengkapp/edge v0.0.17
2
+ * @lengkapp/edge v0.0.19
3
3
  * MIT License
4
4
  *
5
5
  * LengkApp Edge License
@@ -30,4 +30,4 @@
30
30
  * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
31
31
  * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
32
32
  */
33
- var t=Object.defineProperty,e=Object.getOwnPropertyDescriptor,n=Object.getOwnPropertyNames,o=Object.prototype.hasOwnProperty,r=(e,n)=>{for(var o in n)t(e,o,{get:n[o],enumerable:!0})},i=(r,i,s,a)=>{if(i&&"object"==typeof i||"function"==typeof i)for(let c of n(i))o.call(r,c)||c===s||t(r,c,{get:()=>i[c],enumerable:!(a=e(i,c))||a.enumerable});return r},s=e=>i(t({},"__esModule",{value:!0}),e),a={};function c(t){const e=[],n=t.length;let o=1;for(let r=1;r<=n;r++)r!==n&&47!==t.charCodeAt(r)||(r>o&&e.push(t.slice(o,r)),o=r+1);return e}function l(t){const e=t.length;return e>1&&47===t.charCodeAt(e-1)?t.slice(0,-1):t}r(a,{Edge:()=>Edge,Fragment:()=>Fragment,jsx:()=>jsx,renderToString:()=>renderToString}),module.exports=s(a);var u=new Set(["__proto__","constructor","prototype"]);function f(t){return"string"==typeof t&&!u.has(t)}function h(t){const e=JSON.parse(t);if(e&&"object"==typeof e)for(const t of Object.keys(e))if(!f(t))throw new SyntaxError("Forbidden key in JSON payload");return e}var p=class{constructor(t,e,n,o={},r=null){this.req=t,this.env=e,this.executionCtx=n,this.params=o,this.status=200,this.headers=new Headers,this.headers.set("X-Content-Type-Options","nosniff"),this.headers.set("X-Frame-Options","DENY"),this.headers.set("Referrer-Policy","strict-origin-when-cross-origin"),this.headers.set("Permissions-Policy","geolocation=(), microphone=(), camera=()"),this.t=t.headers.get("Cookie")||"",this.o=null,this.url=r||new URL(t.url)}i(){if(null===this.o){const t=Object.create(null);if(this.t)for(const e of this.t.split(";")){const n=e.trim();if(!n)continue;const o=n.indexOf("=");if(o>0)try{const e=decodeURIComponent(n.slice(0,o)),r=decodeURIComponent(n.slice(o+1));f(e)&&(t[e]=r)}catch{}}this.o=t}return this.o}getCookie(t){return this.i()[t]??null}get query(){return this.url.searchParams}setCookie(t,e,n={}){if("string"!=typeof t||!f(t)||/[\r\n;=]/.test(t))return;let o=`${encodeURIComponent(t)}=${encodeURIComponent(e)}`;n.path&&(o+=`; Path=${n.path}`),n.domain&&(o+=`; Domain=${n.domain}`),void 0!==n.maxAge&&(o+=`; Max-Age=${n.maxAge}`),n.expires&&(o+=`; Expires=${n.expires.toUTCString()}`),n.secure&&(o+="; Secure"),n.httpOnly&&(o+="; HttpOnly"),n.sameSite&&(o+=`; SameSite=${n.sameSite}`),this.headers.append("Set-Cookie",o)}deleteCookie(t,e={}){this.setCookie(t,"",{...e,maxAge:0,expires:new Date(0)})}text(t,e=this.status,n={}){const o=this.l(n);return o.has("Content-Type")||o.set("Content-Type","text/plain; charset=utf-8"),new Response(t,{status:e,headers:o})}json(t,e=this.status,n={}){const o=this.l(n);return o.has("Content-Type")||o.set("Content-Type","application/json; charset=utf-8"),new Response(JSON.stringify(t),{status:e,headers:o})}html(t,e=this.status,n={}){const o=this.l(n);return o.has("Content-Type")||o.set("Content-Type","text/html; charset=utf-8"),new Response(t,{status:e,headers:o})}l(t){if(0===Object.keys(t).length)return this.headers;const e=new Headers(this.headers);for(const n in t)Object.prototype.hasOwnProperty.call(t,n)&&e.set(n,t[n]);return e}},Fragment=Symbol("Fragment");function jsx(t,e,...n){return{type:t,props:e||{},children:n.flat(1/0),__isJSX:!0}}var d=new Set(["area","base","br","col","embed","hr","img","input","link","meta","param","source","track","wbr"]),y=new Set(["allowfullscreen","async","autofocus","autoplay","checked","controls","default","defer","disabled","formnovalidate","hidden","inert","ismap","itemscope","loop","multiple","muted","nomodule","novalidate","open","playsinline","readonly","required","reversed","selected"]),m=new Set(["animation-iteration-count","border-image-outset","border-image-slice","border-image-width","box-flex","box-flex-group","box-ordinal-group","column-count","columns","flex","flex-grow","flex-positive","flex-shrink","flex-negative","flex-order","grid-row","grid-row-end","grid-row-span","grid-row-start","grid-column","grid-column-end","grid-column-span","grid-column-start","font-weight","line-clamp","line-height","opacity","order","orphans","tab-size","widows","z-index","zoom","fill-opacity","flood-opacity","stop-opacity","stroke-dasharray","stroke-dashoffset","stroke-miterlimit","stroke-opacity","stroke-width"]),g={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"},w=/[&<>"']/g;function b(t){return w.lastIndex=0,w.test(t)?t.replace(w,t=>g[t]):t}var A=new Map,T=2e3;function _(t){const e=A.get(t);if(void 0!==e)return e;const n=t.replace(/([A-Z])/g,"-$1").toLowerCase().replace(/^-/,"");return A.size>=T&&A.clear(),A.set(t,n),n}function C(t){if(!t||"object"!=typeof t)return"";const e=Object.entries(t);return 0===e.length?"":e.map(([t,e])=>{let n=_(t);return"number"!=typeof e||m.has(n)||(e=`${e}px`),`${n}:${e}`}).join(";")}function renderToString(t){if(null==t||"boolean"==typeof t)return"";if("string"==typeof t||"number"==typeof t)return b(String(t));if(Array.isArray(t)){const e=[];for(let n=0;n<t.length;n++)e.push(renderToString(t[n]));return e.join("")}if(!t.__isJSX)return b(String(t));const{type:e,props:n,children:o}=t;if(e===Fragment){const t=[];for(let e=0;e<o.length;e++)t.push(renderToString(o[e]));return t.join("")}if("symbol"==typeof e)return"";if("function"==typeof e)return renderToString(e({...n,children:o}));if("string"!=typeof e||!/^[A-Za-z][A-Za-z0-9-]*$/.test(e))return"";const r=d.has(e),i=[];for(const t in n){if(!Object.prototype.hasOwnProperty.call(n,t))continue;if("children"===t||"key"===t||"ref"===t)continue;const e=n[t];if(null==e||!1===e)continue;if("dangerouslySetInnerHTML"===t)continue;let o=t;if("className"===t||"class"===t?o="class":"htmlFor"!==t&&"for"!==t||(o="for"),f(o)&&!/^on/i.test(o)&&/^[A-Za-z_:][A-Za-z0-9_:.-]*$/.test(o)){if("href"===o||"src"===o||"xlink:href"===o||"action"===o||"formaction"===o){const t=String(e).replace(/[\u0000-\u0020]/g,"").toLowerCase();if(t.startsWith("javascript:")||t.startsWith("vbscript:")||t.startsWith("data:text/html"))continue}if(y.has(o))!0===e&&i.push(` ${o}`);else if(!0!==e){if("style"===o&&"object"==typeof e){const t=C(e);t&&i.push(` style="${b(t)}"`);continue}i.push(` ${o}="${b(String(e))}"`)}else i.push(` ${o}="true"`)}}const s=i.join("");let a="";if(n&&n.dangerouslySetInnerHTML&&null!=n.dangerouslySetInnerHTML.__html)a=n.dangerouslySetInnerHTML.__html;else{const t=[];for(let e=0;e<o.length;e++)t.push(renderToString(o[e]));a=t.join("")}return r?`<${e}${s}/>`:`<${e}${s}>${a}</${e}>`}var k=class{constructor(){this.children=null,this.paramChild=null,this.handler=null}},O=class{constructor(){this.root=new k}add(t,e,n){let o=this.root;const r=[];for(let e=0;e<t.length;e++){const n=t[e];if(58===n.charCodeAt(0)){const t=n.slice(1);r.push(f(t)?t:`_p${e}`),o.paramChild||(o.paramChild=new k),o=o.paramChild}else{o.children||(o.children=new Map);let t=o.children.get(n);t||(t=new k,o.children.set(n,t)),o=t}}o.handler={handler:e,options:n,paramNames:r}}match(t){const e=[],n=this.u(this.root,t,0,e);if(!n)return null;const{handler:o,options:r,paramNames:i}=n,s=Object.create(null);for(let t=0;t<i.length;t++)s[i[t]]=e[t];return{handler:o,options:r,params:s}}u(t,e,n,o){if(n===e.length)return t.handler;const r=e[n];if(t.children){const i=t.children.get(r);if(i){const t=this.u(i,e,n+1,o);if(t)return t}}if(t.paramChild){o.push(r);const i=this.u(t.paramChild,e,n+1,o);if(i)return i;o.pop()}return null}},S=["GET","POST","PUT","DELETE","PATCH","OPTIONS","HEAD"],$=new Map;function E(t,e,n){const o=`${t}|${e||""}|${n||""}`,r=Date.now(),i=$.get(o);if(i&&r-i<6e4)return!1;if($.set(o,r),$.size>5e3)for(const[t,e]of $)r-e>6e4&&$.delete(t);return!0}var Edge=class{constructor(){this.staticRoutes=new Map;for(const t of S)this.staticRoutes.set(t,new Map);this.dynamicTries={};for(const t of S)this.dynamicTries[t]=new O;this.authKvBinding="AUTH_KV",this.rateLimitKvBinding="RATE_LIMIT_KV",this.defaults={cors:{origin:"*",methods:"GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD"}},this.scheduledHandler=null,this.security={hashAuthTokens:!1,logSecurityEvents:!0,extraHeaders:null,trustedProxies:null}}h(t,e,n,o){if("boolean"==typeof n&&(n={auth:n}),"function"==typeof n&&(o=n,n={}),"function"!=typeof o)throw new Error(`Handler for ${t} ${e} must be a function`);const r={...this.defaults,...n||{}};if(-1===e.indexOf(":")){const n=l(e);this.staticRoutes.get(t).set(n,{handler:o,options:r})}else this.dynamicTries[t].add(c(e),o,r)}get(t,e,n){this.h("GET",t,e,n)}post(t,e,n){this.h("POST",t,e,n)}put(t,e,n){this.h("PUT",t,e,n)}delete(t,e,n){this.h("DELETE",t,e,n)}patch(t,e,n){this.h("PATCH",t,e,n)}options(t,e,n){this.h("OPTIONS",t,e,n)}head(t,e,n){this.h("HEAD",t,e,n)}scheduled(t){this.scheduledHandler=t}p(t,e,n){if(this.security.logSecurityEvents)try{const o=t.req.headers.get("CF-Connecting-IP")||"",r=t.url?t.url.pathname:"";if(!E(e,o,r))return;console.log(JSON.stringify({ts:(new Date).toISOString(),kind:"security",event:e,detail:"string"==typeof n?n:void 0,ip:o||void 0,method:t.req.method,path:r,ua:t.req.headers.get("User-Agent")||void 0}))}catch{}}async m(t,e){if(!e)return{ok:!0};const n=t.getCookie("auth_token")||t.req.headers.get("Authorization")?.replace(/^Bearer\s+/i,"");if(!n)return{ok:!1,reason:"missing_token"};const o=t.env[this.authKvBinding];if(!o)return{ok:!1,reason:"kv_unavailable"};let r,i=n;if(this.security.hashAuthTokens&&globalThis.crypto&&crypto.subtle){const t=await crypto.subtle.digest("SHA-256",(new TextEncoder).encode(n)),e=new Uint8Array(t);let o="";for(let t=0;t<e.length;t++)o+=e[t].toString(16).padStart(2,"0");i="tok:"+o}try{r=await o.get(i)}catch{return{ok:!1,reason:"kv_error"}}if(!r)return{ok:!1,reason:"invalid_token"};if("object"==typeof e&&null!==e){let t;try{t=h(r)}catch{return{ok:!1,reason:"malformed_payload"}}if(t&&"object"==typeof t){if(null!=t.exp){const e=Date.now()/1e3;if("number"==typeof t.exp&&t.exp<e)return{ok:!1,reason:"expired_token"}}if(e.role&&t.role!==e.role)return{ok:!1,reason:"insufficient_role"};if(e.scopes){const n=Array.isArray(t.scopes)?t.scopes:[];for(let t=0;t<e.scopes.length;t++)if(-1===n.indexOf(e.scopes[t]))return{ok:!1,reason:"insufficient_scopes"}}}}return{ok:!0,payload:r}}async A(t,e){if(!e)return!0;const n=!0===e?{}:e,{max:o=100,window:r=60}=n,i=`rl:${t.req.headers.get("CF-Connecting-IP")||"unknown"}`,s=t.env[this.rateLimitKvBinding];if(!s)return!0;let a;try{a=await s.get(i,"json")}catch{return!0}if(("number"!=typeof a||!isFinite(a)||a<0)&&(a=0),a>=o)return!1;a++;try{await s.put(i,JSON.stringify(a),{expirationTtl:r})}catch{}return!0}T(t,e){if(!e)return;const n="object"==typeof e?e:this.defaults.cors,o=t.req.headers.get("Origin"),r=n.origin;"*"===r?t.headers.set("Access-Control-Allow-Origin","*"):Array.isArray(r)?o&&-1!==r.indexOf(o)&&(t.headers.set("Access-Control-Allow-Origin",o),t.headers.set("Vary","Origin"),t.headers.set("Access-Control-Allow-Credentials","true")):"string"==typeof r&&o===r&&(t.headers.set("Access-Control-Allow-Origin",o),t.headers.set("Vary","Origin"),t.headers.set("Access-Control-Allow-Credentials","true")),t.headers.set("Access-Control-Allow-Methods",n.methods||"GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD"),t.headers.set("Access-Control-Allow-Headers",n.headers||"Content-Type, Authorization, X-CSRF-Token"),t.headers.set("Access-Control-Max-Age","86400")}_(t,e){e&&console.log(`${t.req.method} ${t.req.url} - ${t.status}`)}C(t,e){const n=t.headers.get("Accept-Encoding")||"";let o=null;if(n.includes("gzip")?o="gzip":n.includes("deflate")&&(o="deflate"),!o||!e.body)return e;const r=e.body.pipeThrough(new CompressionStream(o)),i=new Headers(e.headers);return i.set("Content-Encoding",o),i.set("Vary","Accept-Encoding"),i.delete("Content-Length"),new Response(r,{status:e.status,statusText:e.statusText,headers:i})}async k(t,e){return!e||"function"!=typeof e||!!await e(t)}async O(t){return await caches.default.match(t)||null}async S(t,e,n){if(!n)return;const o=!0===n?{}:n,{ttl:r=3600,staleWhileRevalidate:i=0}=o,s=e.clone();s.headers.set("Cache-Control",`max-age=${r}${i>0?`, stale-while-revalidate=${i}`:""}`),s.headers.delete("Set-Cookie"),await caches.default.put(t,s)}async fetch(t,e,n){const o=new URL(t.url),r=l(o.pathname),i=t.method,s=this.staticRoutes.get(i);if(s){const i=s.get(r);if(i)return this.$(i.handler,i.options,t,e,n,Object.create(null),o)}const a=this.dynamicTries[i];if(a){const i=a.match(c(r));if(i)return this.$(i.handler,i.options,t,e,n,i.params,o)}return new Response("Not Found",{status:404})}async $(t,e,n,o,r,i,s){const a=new p(n,o,r,i,s);if(this.security.extraHeaders)for(const t in this.security.extraHeaders)Object.prototype.hasOwnProperty.call(this.security.extraHeaders,t)&&a.headers.set(t,this.security.extraHeaders[t]);let c,l=!1;try{l=await this.k(a,e.validate)}catch(t){this.p(a,"validation_error",t&&t.message),l=!1}if(!l)return a.text("Validation failed",400);try{c=await this.m(a,e.auth)}catch(t){this.p(a,"auth_error",t&&t.message),c={ok:!1,reason:"auth_error"}}if(!("boolean"==typeof c?c:c&&c.ok))return this.p(a,"auth_failure",c&&c.reason||"denied"),a.text("Unauthorized",401);let u,f=!1;try{f=await this.A(a,e.rateLimit)}catch(t){this.p(a,"rate_limit_error",t&&t.message),f=!1}if(!f)return this.p(a,"rate_limit_exceeded",null),a.text("Too Many Requests",429,{"Retry-After":"60"});if(e.cache&&"GET"===n.method){const t=await this.O(n);if(t)return this.T(a,e.cors),this._(a,e.log),t}this.T(a,e.cors);try{const e=await t(a);u=e&&e.__isJSX?a.html(renderToString(e)):e instanceof Response?e:a.text("OK"),a.status=u.status}catch(t){this.p(a,"handler_error",t&&t.message),u=a.text("Internal Server Error",500),a.status=500}return e.cache&&"GET"===n.method&&200===u.status&&a.executionCtx.waitUntil(this.S(n,u.clone(),e.cache)),e.compress&&(u=this.C(n,u)),this._(a,e.log),u}};
33
+ var t=Object.defineProperty,e=Object.getOwnPropertyDescriptor,n=Object.getOwnPropertyNames,r=Object.prototype.hasOwnProperty,o=(e,n)=>{for(var r in n)t(e,r,{get:n[r],enumerable:!0})},i=(o,i,s,a)=>{if(i&&"object"==typeof i||"function"==typeof i)for(let c of n(i))r.call(o,c)||c===s||t(o,c,{get:()=>i[c],enumerable:!(a=e(i,c))||a.enumerable});return o},s=e=>i(t({},"__esModule",{value:!0}),e),a={};function c(t){const e=[],n=t.length;let r=1;for(let o=1;o<=n;o++)o!==n&&47!==t.charCodeAt(o)||(o>r&&e.push(t.slice(r,o)),r=o+1);return e}function l(t){const e=t.length;return e>1&&47===t.charCodeAt(e-1)?t.slice(0,-1):t}o(a,{Context:()=>T,Edge:()=>Edge,Fragment:()=>Fragment,jsx:()=>jsx,renderToString:()=>renderToString}),module.exports=s(a);var u=new Set(["__proto__","constructor","prototype"]);function f(t){return"string"==typeof t&&!u.has(t)}function h(t){const e=JSON.parse(t);if(e&&"object"==typeof e)for(const t of Object.keys(e))if(!f(t))throw new SyntaxError("Forbidden key in JSON payload");return e}function p(t){const e=t.length;let n=0;for(;n<e;){const e=t.charCodeAt(n);if(38===e||60===e||62===e||34===e||39===e)break;n++}if(n===e)return t;let r=t.slice(0,n),o=n;for(;n<e;n++){const e=t.charCodeAt(n);let i;if(38===e)i="&amp;";else if(60===e)i="&lt;";else if(62===e)i="&gt;";else if(34===e)i="&quot;";else{if(39!==e)continue;i="&#039;"}n>o&&(r+=t.slice(o,n)),r+=i,o=n+1}return o<e&&(r+=t.slice(o)),r}var Fragment=Symbol("Fragment");function jsx(t,e,...n){return{type:t,props:e||{},children:n,__isJSX:!0}}var d=new Set(["area","base","br","col","embed","hr","img","input","link","meta","param","source","track","wbr"]),y=new Set(["allowfullscreen","async","autofocus","autoplay","checked","controls","default","defer","disabled","formnovalidate","hidden","inert","ismap","itemscope","loop","multiple","muted","nomodule","novalidate","open","playsinline","readonly","required","reversed","selected"]),m=new Set(["animation-iteration-count","border-image-outset","border-image-slice","border-image-width","box-flex","box-flex-group","box-ordinal-group","column-count","columns","flex","flex-grow","flex-positive","flex-shrink","flex-negative","flex-order","grid-row","grid-row-end","grid-row-span","grid-row-start","grid-column","grid-column-end","grid-column-span","grid-column-start","font-weight","line-clamp","line-height","opacity","order","orphans","tab-size","widows","z-index","zoom","fill-opacity","flood-opacity","stop-opacity","stroke-dasharray","stroke-dashoffset","stroke-miterlimit","stroke-opacity","stroke-width"]),g=new Map,w=2e3;function b(t){const e=g.get(t);if(void 0!==e)return e;const n=t.replace(/([A-Z])/g,"-$1").toLowerCase().replace(/^-/,"");return g.size>=w&&g.clear(),g.set(t,n),n}function A(t){if(!t||"object"!=typeof t)return"";let e="";for(const n in t){if(!Object.prototype.hasOwnProperty.call(t,n))continue;const r=b(n);let o=t[n];"number"!=typeof o||m.has(r)||(o+="px"),e&&(e+=";"),e+=r+":"+o}return e}var C=new Set(["href","src","xlink:href","action","formaction"]);function renderToString(t){if(null==t||"boolean"==typeof t)return"";const e=typeof t;if("string"===e)return p(t);if("number"===e)return p(String(t));if(Array.isArray(t)){let e="";for(let n=0;n<t.length;n++)e+=renderToString(t[n]);return e}if(!t.__isJSX)return p(String(t));const{type:n,props:r,children:o}=t;if(n===Fragment){let t="";for(let e=0;e<o.length;e++)t+=renderToString(o[e]);return t}if("symbol"==typeof n)return"";if("function"==typeof n)return renderToString(n({...r,children:o}));if("string"!=typeof n||!/^[A-Za-z][A-Za-z0-9-]*$/.test(n))return"";const i=d.has(n);let s="";for(const t in r){if(!Object.prototype.hasOwnProperty.call(r,t))continue;if("children"===t||"key"===t||"ref"===t)continue;const e=r[t];if(null==e||!1===e)continue;if("dangerouslySetInnerHTML"===t)continue;let n=t;if("className"===t||"class"===t?n="class":"htmlFor"!==t&&"for"!==t||(n="for"),f(n)&&!/^on/i.test(n)&&/^[A-Za-z_:][A-Za-z0-9_:.-]*$/.test(n)){if(C.has(n)){const t=String(e).replace(/[\u0000-\u0020]/g,"").toLowerCase();if(t.startsWith("javascript:")||t.startsWith("vbscript:")||t.startsWith("data:text/html"))continue}if(y.has(n))!0===e&&(s+=" "+n);else if(!0!==e){if("style"===n&&"object"==typeof e){const t=A(e);t&&(s+=' style="'+p(t)+'"');continue}s+=" "+n+'="'+p(String(e))+'"'}else s+=" "+n+'="true"'}}let a="";const c=r.dangerouslySetInnerHTML;if(c&&null!=c.__html)a=c.__html;else for(let t=0;t<o.length;t++)a+=renderToString(o[t]);return i?"<"+n+s+"/>":"<"+n+s+">"+a+"</"+n+">"}var T=class{constructor(t,e,n,r={},o=null){this.req=t,this.env=e,this.executionCtx=n,this.params=r,this.status=200,this.headers=new Headers,this.headers.set("X-Content-Type-Options","nosniff"),this.headers.set("X-Frame-Options","DENY"),this.headers.set("Referrer-Policy","strict-origin-when-cross-origin"),this.headers.set("Permissions-Policy","geolocation=(), microphone=(), camera=()"),this.t=t.headers.get("Cookie")||"",this.o=null,this.url=o||new URL(t.url)}i(){if(null!==this.o)return this.o;const t=Object.create(null),e=this.t;if(e){let n=0;const r=e.length;for(;n<r;){let o=e.indexOf(";",n);-1===o&&(o=r);let i=n,s=o;for(;i<s&&32===e.charCodeAt(i);)i++;for(;s>i&&32===e.charCodeAt(s-1);)s--;if(s>i){const n=e.indexOf("=",i);if(n>i&&n<s)try{const r=decodeURIComponent(e.slice(i,n));f(r)&&(t[r]=decodeURIComponent(e.slice(n+1,s)))}catch{}}n=o+1}}return this.o=t,t}getCookie(t){const e=this.i()[t];return void 0===e?null:e}get query(){return this.url.searchParams}setCookie(t,e,n={}){if("string"!=typeof t||!f(t)||/[\r\n;=]/.test(t))return;let r=encodeURIComponent(t)+"="+encodeURIComponent(e);n.path&&(r+="; Path="+n.path),n.domain&&(r+="; Domain="+n.domain),void 0!==n.maxAge&&(r+="; Max-Age="+n.maxAge),n.expires&&(r+="; Expires="+n.expires.toUTCString()),n.secure&&(r+="; Secure"),n.httpOnly&&(r+="; HttpOnly"),n.sameSite&&(r+="; SameSite="+n.sameSite),this.headers.append("Set-Cookie",r)}deleteCookie(t,e={}){this.setCookie(t,"",{...e,maxAge:0,expires:new Date(0)})}l(t){if(!t)return this.headers;let e=!1;for(const n in t){e=!0;break}if(!e)return this.headers;const n=new Headers(this.headers);for(const e in t)Object.prototype.hasOwnProperty.call(t,e)&&n.set(e,t[e]);return n}text(t,e=this.status,n={}){const r=this.l(n);return r.has("Content-Type")||r.set("Content-Type","text/plain; charset=utf-8"),new Response(t,{status:e,headers:r})}json(t,e=this.status,n={}){const r=this.l(n);return r.has("Content-Type")||r.set("Content-Type","application/json; charset=utf-8"),new Response(JSON.stringify(t),{status:e,headers:r})}html(t,e=this.status,n={}){const r=this.l(n);r.has("Content-Type")||r.set("Content-Type","text/html; charset=utf-8");const o="string"==typeof t?t:t&&t.__isJSX||Array.isArray(t)?renderToString(t):null==t?"":String(t);return new Response(o,{status:e,headers:r})}},_=class{constructor(){this.children=null,this.paramChild=null,this.handler=null}},k=class{constructor(){this.root=new _}add(t,e,n){let r=this.root;const o=[];for(let e=0;e<t.length;e++){const n=t[e];if(58===n.charCodeAt(0)){const t=n.slice(1);o.push(f(t)?t:"_p"+e),r.paramChild||(r.paramChild=new _),r=r.paramChild}else{r.children||(r.children=new Map);let t=r.children.get(n);t||(t=new _,r.children.set(n,t)),r=t}}r.handler={handler:e,options:n,paramNames:o}}match(t){const e=[],n=this.u(this.root,t,0,e);if(!n)return null;const{handler:r,options:o,paramNames:i}=n,s=Object.create(null);for(let t=0;t<i.length;t++)s[i[t]]=e[t];return{handler:r,options:o,params:s}}u(t,e,n,r){if(n===e.length)return t.handler;const o=e[n],i=t.children;if(i){const t=i.get(o);if(t){const o=this.u(t,e,n+1,r);if(o)return o}}const s=t.paramChild;if(s){r.push(o);const t=this.u(s,e,n+1,r);if(t)return t;r.pop()}return null}},O=["GET","POST","PUT","DELETE","PATCH","OPTIONS","HEAD"],S=new Map;function E(t,e,n){const r=t+"|"+(e||"")+"|"+(n||""),o=Date.now(),i=S.get(r);if(i&&o-i<6e4)return!1;if(S.set(r,o),S.size>5e3)for(const[t,e]of S)o-e>6e4&&S.delete(t);return!0}var Edge=class{constructor(){this.staticRoutes=new Map;for(let t=0;t<O.length;t++)this.staticRoutes.set(O[t],new Map);this.dynamicTries={};for(let t=0;t<O.length;t++)this.dynamicTries[O[t]]=new k;this.authKvBinding="AUTH_KV",this.rateLimitKvBinding="RATE_LIMIT_KV",this.defaults={cors:{origin:"*",methods:"GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD"}},this.scheduledHandler=null,this.security={hashAuthTokens:!1,logSecurityEvents:!0,extraHeaders:null,trustedProxies:null}}h(t,e,n,r){if("boolean"==typeof n?n={auth:n}:"function"==typeof n&&(r=n,n={}),"function"!=typeof r)throw new Error("Handler for "+t+" "+e+" must be a function");const o={...this.defaults,...n||{}};-1===e.indexOf(":")?this.staticRoutes.get(t).set(l(e),{handler:r,options:o}):this.dynamicTries[t].add(c(e),r,o)}get(t,e,n){this.h("GET",t,e,n)}post(t,e,n){this.h("POST",t,e,n)}put(t,e,n){this.h("PUT",t,e,n)}delete(t,e,n){this.h("DELETE",t,e,n)}patch(t,e,n){this.h("PATCH",t,e,n)}options(t,e,n){this.h("OPTIONS",t,e,n)}head(t,e,n){this.h("HEAD",t,e,n)}scheduled(t){this.scheduledHandler=t}p(t,e,n){if(this.security.logSecurityEvents)try{const r=t.req.headers.get("CF-Connecting-IP")||"",o=t.url?t.url.pathname:"";if(!E(e,r,o))return;console.log(JSON.stringify({ts:(new Date).toISOString(),kind:"security",event:e,detail:"string"==typeof n?n:void 0,ip:r||void 0,method:t.req.method,path:o,ua:t.req.headers.get("User-Agent")||void 0}))}catch{}}async m(t,e){if(!e)return{ok:!0};const n=t.getCookie("auth_token")||t.req.headers.get("Authorization")?.replace(/^Bearer\s+/i,"");if(!n)return{ok:!1,reason:"missing_token"};const r=t.env[this.authKvBinding];if(!r)return{ok:!1,reason:"kv_unavailable"};let o,i=n;if(this.security.hashAuthTokens&&globalThis.crypto&&crypto.subtle){const t=await crypto.subtle.digest("SHA-256",(new TextEncoder).encode(n)),e=new Uint8Array(t);let r="";for(let t=0;t<e.length;t++)r+=e[t].toString(16).padStart(2,"0");i="tok:"+r}try{o=await r.get(i)}catch{return{ok:!1,reason:"kv_error"}}if(!o)return{ok:!1,reason:"invalid_token"};if("object"==typeof e&&null!==e){let t;try{t=h(o)}catch{return{ok:!1,reason:"malformed_payload"}}if(t&&"object"==typeof t){if(null!=t.exp&&"number"==typeof t.exp&&t.exp<Date.now()/1e3)return{ok:!1,reason:"expired_token"};if(e.role&&t.role!==e.role)return{ok:!1,reason:"insufficient_role"};if(e.scopes){const n=Array.isArray(t.scopes)?t.scopes:[];for(let t=0;t<e.scopes.length;t++)if(-1===n.indexOf(e.scopes[t]))return{ok:!1,reason:"insufficient_scopes"}}}}return{ok:!0,payload:o}}async A(t,e){if(!e)return!0;const n=!0===e?{}:e,r=n.max??100,o=n.window??60,i="rl:"+(t.req.headers.get("CF-Connecting-IP")||"unknown"),s=t.env[this.rateLimitKvBinding];if(!s)return!0;let a;try{a=await s.get(i,"json")}catch{return!0}if(("number"!=typeof a||!isFinite(a)||a<0)&&(a=0),a>=r)return!1;a++;try{await s.put(i,JSON.stringify(a),{expirationTtl:o})}catch{}return!0}C(t,e){if(!e)return;const n="object"==typeof e?e:this.defaults.cors,r=t.req.headers.get("Origin"),o=n.origin;"*"===o?t.headers.set("Access-Control-Allow-Origin","*"):Array.isArray(o)?r&&-1!==o.indexOf(r)&&(t.headers.set("Access-Control-Allow-Origin",r),t.headers.set("Vary","Origin"),t.headers.set("Access-Control-Allow-Credentials","true")):"string"==typeof o&&r===o&&(t.headers.set("Access-Control-Allow-Origin",r),t.headers.set("Vary","Origin"),t.headers.set("Access-Control-Allow-Credentials","true")),t.headers.set("Access-Control-Allow-Methods",n.methods||"GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD"),t.headers.set("Access-Control-Allow-Headers",n.headers||"Content-Type, Authorization, X-CSRF-Token"),t.headers.set("Access-Control-Max-Age","86400")}T(t,e){e&&console.log(t.req.method+" "+t.req.url+" - "+t.status)}_(t,e){const n=t.headers.get("Accept-Encoding")||"";let r=null;if(-1!==n.indexOf("gzip")?r="gzip":-1!==n.indexOf("deflate")&&(r="deflate"),!r||!e.body)return e;const o=e.body.pipeThrough(new CompressionStream(r)),i=new Headers(e.headers);return i.set("Content-Encoding",r),i.set("Vary","Accept-Encoding"),i.delete("Content-Length"),new Response(o,{status:e.status,statusText:e.statusText,headers:i})}async k(t,e){return!e||"function"!=typeof e||!!await e(t)}async O(t){return await caches.default.match(t)||null}async S(t,e,n){if(!n)return;const r=!0===n?{}:n,o=r.ttl??3600,i=r.staleWhileRevalidate??0,s=e.clone();s.headers.set("Cache-Control","max-age="+o+(i>0?", stale-while-revalidate="+i:"")),s.headers.delete("Set-Cookie"),await caches.default.put(t,s)}async fetch(t,e,n){const r=new URL(t.url),o=l(r.pathname),i=t.method,s=this.staticRoutes.get(i);if(s){const i=s.get(o);if(i)return this.v(i.handler,i.options,t,e,n,Object.create(null),r)}const a=this.dynamicTries[i];if(a){const i=a.match(c(o));if(i)return this.v(i.handler,i.options,t,e,n,i.params,r)}return new Response("Not Found",{status:404})}async v(t,e,n,r,o,i,s){const a=new T(n,r,o,i,s),c=this.security.extraHeaders;if(c)for(const t in c)Object.prototype.hasOwnProperty.call(c,t)&&a.headers.set(t,c[t]);let l,u=!1;try{u=await this.k(a,e.validate)}catch(t){this.p(a,"validation_error",t&&t.message),u=!1}if(!u)return a.text("Validation failed",400);try{l=await this.m(a,e.auth)}catch(t){this.p(a,"auth_error",t&&t.message),l={ok:!1,reason:"auth_error"}}if(!("boolean"==typeof l?l:l&&l.ok))return this.p(a,"auth_failure",l&&l.reason||"denied"),a.text("Unauthorized",401);let f,h=!1;try{h=await this.A(a,e.rateLimit)}catch(t){this.p(a,"rate_limit_error",t&&t.message),h=!1}if(!h)return this.p(a,"rate_limit_exceeded",null),a.text("Too Many Requests",429,{"Retry-After":"60"});if(e.cache&&"GET"===n.method){const t=await this.O(n);if(t)return this.C(a,e.cors),this.T(a,e.log),t}this.C(a,e.cors);try{const e=await t(a);f=e instanceof Response?e:e&&e.__isJSX?a.html(e):a.text("OK"),a.status=f.status}catch(t){this.p(a,"handler_error",t&&t.message),f=a.text("Internal Server Error",500),a.status=500}return e.cache&&"GET"===n.method&&200===f.status&&a.executionCtx.waitUntil(this.S(n,f.clone(),e.cache)),e.compress&&(f=this._(n,f)),this.T(a,e.log),f}};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lengkapp/edge",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "description": "Edge framework used by Lengkapp",
5
5
  "main": "dist/edge-server.js",
6
6
  "types": "dist/edge-server.d.ts",
package/readme.md CHANGED
@@ -2,20 +2,37 @@
2
2
 
3
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
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.
5
+ Inspired by [Hono](https://hono.dev), @lengkapp/edge aims for the same class of performance while shipping with **zero runtime dependencies**.
6
+
7
+ > **Security:** Server and client are hardened against the OWASP Top 10:2025 and ASVS 5.0 Layer 1 controls. The client sanitizes every response, works under `require-trusted-types-for 'script'` CSP, is nonce-aware for script execution, and sends CSRF tokens on state-changing requests. See [Security](#security-controls).
6
8
 
7
9
  ---
8
10
 
9
11
  ## Features
10
12
 
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.
13
+ ### Server
14
+ - **Trie-based routing** – static & dynamic routes (`/users/:id`).
15
+ - **JSX support** – pass JSX straight to `ctx.html(<Card />)`; no build step, no manual `renderToString` call.
16
+ - **Middleware** – auth, rate limiting, CORS, logging, caching, compression, validation.
17
+ - **Cookie helpers** with validation.
18
+ - **Scheduled tasks** via Cron triggers.
19
+ - **Zero dependencies.**
20
+
21
+ ### Client
22
+ - **Declarative partial updates** via `_get`, `_post`, `_target`, `_trigger`.
23
+ - **Trusted Types aware** – routes all `innerHTML` through an `edge-policy` sanitizer.
24
+ - **Nonce-aware script execution** – response scripts must match the page's CSP nonce when one exists.
25
+ - **Built-in sanitizer** – strips `<base>`, `<meta refresh>`, `<iframe srcdoc>`, `<object>`, `<embed>`, and `javascript:`/`vbscript:`/`data:text/html` URLs.
26
+ - **CSRF-aware** – sends `X-CSRF-Token` on `POST`/`PUT`/`PATCH`/`DELETE`.
27
+ - **Content-Type guarded** – only `text/html` and `text/plain` responses are inserted.
28
+
29
+ ### Security (both)
30
+ - **Prototype-pollution safe** – route params, cookies, JSON bodies, JSX attributes.
31
+ - **XSS-hardened JSX** – no `on*` attributes, no `javascript:` URLs, no malformed tag names.
32
+ - **Structured security logging** – throttled JSON events for auth/rate-limit/handler failures.
33
+ - **Fail-closed middleware** – validation, auth, and rate limit errors deny by default.
34
+ - **Opt-in token hashing** – SHA-256 before KV lookup.
35
+ - **Strict CORS allowlist** – per-origin reflection with `Vary: Origin`.
19
36
 
20
37
  ---
21
38
 
@@ -25,375 +42,264 @@ Inspired by [Hono](https://hono.dev), @lengkapp/edge aims for the same class of
25
42
  npm install @lengkapp/edge
26
43
  ```
27
44
 
28
- ---
29
-
30
45
  ## Quick Start
31
46
 
32
- **1. Create a worker script (`worker.js` or `src/index.ts`):**
47
+ `worker.js`:
33
48
 
34
49
  ```ts
35
- import { Edge, renderToString } from '@lengkapp/edge';
50
+ import { Edge } from '@lengkapp/edge';
36
51
 
37
52
  const app = new Edge();
38
53
 
39
54
  app.get('/', (ctx) => ctx.text('Hello World!'));
40
-
41
- app.get('/users/:id', (ctx) => {
42
- return ctx.json({ id: ctx.params.id });
43
- });
55
+ app.get('/users/:id', (ctx) => ctx.json({ id: ctx.params.id }));
44
56
 
45
57
  export default {
46
58
  fetch: (request, env, ctx) => app.fetch(request, env, ctx),
47
59
  };
48
60
  ```
49
61
 
50
- **2. Create `wrangler.jsonc`:**
51
-
52
- Cloudflare recommends `wrangler.jsonc` for new projects (TOML is still supported — see [Configuration](#configuration)).
62
+ `wrangler.jsonc`:
53
63
 
54
64
  ```jsonc
55
65
  {
56
66
  "$schema": "./node_modules/wrangler/config-schema.json",
57
67
  "name": "my-edge-app",
58
68
  "main": "worker.js",
59
- // Set this to today's date
60
69
  "compatibility_date": "2026-09-06",
61
- "observability": {
62
- "enabled": true
63
- }
70
+ "observability": { "enabled": true }
64
71
  }
65
72
  ```
66
73
 
67
- **3. Deploy:**
74
+ Deploy:
68
75
 
69
76
  ```bash
70
77
  wrangler deploy
71
78
  ```
72
79
 
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
80
  ---
76
81
 
77
82
  ## Server API
78
83
 
79
- ### Creating an App
84
+ ### Context
85
+
86
+ | Member | Description |
87
+ |---|---|
88
+ | `ctx.req` | Incoming Request |
89
+ | `ctx.env` | Environment bindings |
90
+ | `ctx.executionCtx` | ExecutionContext |
91
+ | `ctx.params` | Prototype-safe route params object |
92
+ | `ctx.status` | Default response status (200) |
93
+ | `ctx.headers` | Response Headers |
94
+ | `ctx.query` | URLSearchParams |
95
+ | `ctx.getCookie(name)` | Read a cookie |
96
+ | `ctx.setCookie(name, value, options)` | Set a cookie (name validated) |
97
+ | `ctx.deleteCookie(name, options)` | Delete a cookie |
98
+ | `ctx.text(data, status?, headers?)` | Plain-text response |
99
+ | `ctx.json(data, status?, headers?)` | JSON response |
100
+ | `ctx.html(data, status?, headers?)` | HTML response — accepts a raw string, a JSX element, or an array of JSX elements |
101
+
102
+ Every response carries `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, and a restrictive `Permissions-Policy`.
103
+
104
+ ### Route Options
80
105
 
81
106
  ```ts
82
- import { Edge } from '@lengkapp/edge';
83
- const app = new Edge();
107
+ app.get('/protected', { auth: true }, handler);
108
+ app.get('/admin', { auth: { role: 'admin' } }, handler);
109
+ app.get('/scoped', { auth: { scopes: ['read', 'write'] } }, handler);
110
+ app.get('/limited', { rateLimit: { max: 100, window: 60 } }, handler);
111
+ app.get('/cached', { cache: { ttl: 60 } }, handler);
112
+ app.get('/api', { cors: true }, handler);
113
+ app.post('/submit', { validate: (ctx) => /* ... */ true }, handler);
84
114
  ```
85
115
 
86
- ### Routing
116
+ **Auth:** Tokens are looked up in `AUTH_KV`. Send via `Authorization: Bearer <token>` or the `auth_token` cookie. Payloads with an `exp` field (Unix seconds) are rejected when expired.
87
117
 
88
- Register routes using HTTP method helpers. Both static and dynamic paths are supported.
118
+ **CORS:** The default is `origin: '*'`. For production, use a strict allowlist:
89
119
 
90
120
  ```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
- });
121
+ app.defaults.cors.origin = ['https://app.example.com'];
99
122
  ```
100
123
 
101
- Supported methods: `get`, `post`, `put`, `delete`, `patch`, `options`, `head`.
124
+ **Rate limit:** `429` responses carry `Retry-After: 60`.
102
125
 
103
- ### Context Object
126
+ ### JSX Support
104
127
 
105
- Each handler receives a `Context` object (`ctx`) with the following members:
128
+ `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.
106
129
 
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)).
130
+ ```tsx
131
+ function Card({ title }) {
132
+ return <div class="card"><h2>{title}</h2></div>;
133
+ }
129
134
 
130
- ```ts
131
- app.get('/json', (ctx) => {
132
- return { hello: 'world' }; // → ctx.json()
133
- });
135
+ // Pass JSX straight to ctx.html — no manual renderToString needed.
136
+ app.get('/card', (ctx) => ctx.html(<Card title="Hello" />));
137
+ app.get('/heading', (ctx) => ctx.html(<h1>hello</h1>));
138
+ app.get('/list', (ctx) => ctx.html([<Card title="A" />, <Card title="B" />]));
134
139
 
135
- app.get('/html', (ctx) => {
136
- return '<h1>Hi</h1>'; // → ctx.html()
137
- });
140
+ // Raw strings still work as before.
141
+ app.get('/raw', (ctx) => ctx.html('<p>pre-rendered</p>'));
138
142
  ```
139
143
 
140
- ### Route Options
141
-
142
- Pass an options object as the second argument (before the handler) to enable middleware.
143
-
144
- #### Authentication (`auth`)
144
+ `renderToString` is still exported for advanced use cases (for example, embedding rendered HTML inside another response body or email template):
145
145
 
146
- Requires an `AUTH_KV` KV binding. Valid tokens are stored as keys with a JSON payload.
146
+ ```tsx
147
+ import { renderToString } from '@lengkapp/edge';
147
148
 
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);
149
+ const html = renderToString(<Card title="Hello" />);
152
150
  ```
153
151
 
154
- The client must send the token via `Authorization: Bearer <token>` or a cookie named `auth_token`.
152
+ **`renderToString` hardening:**
153
+ - Tag names must match `^[A-Za-z][A-Za-z0-9-]*$`.
154
+ - Attribute names must match `^[A-Za-z_:][A-Za-z0-9_:.-]*$`.
155
+ - `on*` attributes never serialize.
156
+ - `href`/`src`/`action`/`formaction`/`xlink:href` values beginning with `javascript:`, `vbscript:`, or `data:text/html` are stripped.
157
+ - Prototype keys (`__proto__`, `constructor`, `prototype`) are rejected.
158
+ - All string values are HTML-escaped.
155
159
 
156
- #### Rate Limiting (`rateLimit`)
160
+ ---
157
161
 
158
- Requires a `RATE_LIMIT_KV` binding.
162
+ ## Client (Declarative Partial Updates)
159
163
 
160
- ```ts
161
- app.get('/limited', { rateLimit: { max: 100, window: 60 } }, handler);
164
+ ```html
165
+ <script src="https://cdn.example.com/edge-client.min.js"></script>
162
166
  ```
163
167
 
164
- #### Caching (`cache`)
168
+ ### Attributes
165
169
 
166
- Caches successful GET responses in the Cloudflare cache.
170
+ | Attribute | Description |
171
+ |---|---|
172
+ | `_get` / `_post` / `_put` / `_patch` / `_delete` | URL + method |
173
+ | `_target` | CSS selector, or `"this"` |
174
+ | `_trigger` | Event list: `click`, `load`, `visible`, `intersect`, etc. |
175
+ | `_form` | Form ID to serialize as URL-encoded body |
176
+ | `_json` | Comma-separated input names to send as JSON |
177
+ | `_skeleton` | `"false"` disables the loading skeleton |
178
+ | `_retry` | `"false"` disables the retry button |
167
179
 
168
- ```ts
169
- app.get('/cached', { cache: { ttl: 60, staleWhileRevalidate: 30 } }, handler);
170
- ```
180
+ ### Examples
171
181
 
172
- #### CORS (`cors`)
182
+ ```html
183
+ <button _get="/more-posts" _target="#posts">Load More</button>
173
184
 
174
- Enables CORS headers.
185
+ <div _get="/user-profile" _target="this"></div>
175
186
 
176
- ```ts
177
- app.get('/api', { cors: true }, handler);
187
+ <button _post="/login" _json="username,password" _target="#status">Login</button>
178
188
 
179
- // Custom origin / methods
180
- app.get('/api', { cors: { origin: 'https://example.com', methods: 'GET,POST' } }, handler);
189
+ <div _get="/lazy" _target="this" _trigger="visible"></div>
181
190
  ```
182
191
 
183
- #### Logging (`log`)
184
-
185
- Logs method, URL, and status to the console.
192
+ ### How Content Is Inserted
186
193
 
187
- #### Compression (`compress`)
194
+ 1. Response is fetched with `credentials: 'same-origin'` and `X-Requested-With: XMLHttpRequest`.
195
+ 2. `Content-Type` is verified to be `text/html` or `text/plain`.
196
+ 3. `res.headers` may contain `x-css-required` / `x-js-required` (with optional `x-css-integrity` / `x-js-integrity` for SRI) — matching resources are injected once.
197
+ 4. The HTML is run through `sanitizeHtml`, which removes `<base>`, `<meta refresh>`, `<iframe srcdoc>`, `<object>`, `<embed>`, and neutralizes `javascript:` / `vbscript:` / `data:text/html` URLs.
198
+ 5. The sanitized HTML is assigned via `innerHTML` (through a Trusted Types policy named `edge-policy` when available).
199
+ 6. Each `<script>` in the result is rebuilt in place so the browser executes it. If the page has a CSP nonce, only scripts carrying the matching nonce run; otherwise all scripts run.
188
200
 
189
- Compresses the response using gzip/deflate/br if the client supports it.
201
+ ---
190
202
 
191
- #### Custom Validation (`validate`)
203
+ ## CSRF Protection
192
204
 
193
- A function returning `true`/`false` (or a `Promise`) to allow/deny the request.
205
+ The client reads a token from `<meta name="csrf-token" content="...">` or the `XSRF-TOKEN` cookie and sends it as `X-CSRF-Token` on `POST`/`PUT`/`PATCH`/`DELETE`.
194
206
 
195
207
  ```ts
196
208
  app.post('/submit', {
197
- validate: (ctx) => ctx.req.headers.get('Authorization') === 'Bearer secret'
209
+ validate: (ctx) => {
210
+ const sent = ctx.req.headers.get('X-CSRF-Token');
211
+ return sent && sent === ctx.getCookie('csrf_token');
212
+ }
198
213
  }, handler);
199
214
  ```
200
215
 
201
216
  ---
202
217
 
203
- ## JSX Support
218
+ ## Trusted Types / CSP
204
219
 
205
- The package includes a minimal JSX runtime. Write components as functions returning JSX.
220
+ 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:
206
221
 
207
- ```tsx
208
- import { jsx, renderToString, Fragment } from '@lengkapp/edge';
222
+ ```
223
+ Content-Security-Policy:
224
+ default-src 'self';
225
+ script-src 'self';
226
+ style-src 'self';
227
+ img-src 'self' data:;
228
+ require-trusted-types-for 'script';
229
+ trusted-types edge-policy;
230
+ frame-ancestors 'none';
231
+ base-uri 'self';
232
+ ```
209
233
 
210
- function Card({ title }) {
211
- return (
212
- <div class="card">
213
- <h2>{title}</h2>
214
- </div>
215
- );
216
- }
234
+ ---
217
235
 
218
- app.get('/card', (ctx) => {
219
- return ctx.html(renderToString(<Card title="Hello" />));
220
- });
221
- ```
236
+ ## Security Controls
237
+
238
+ ```ts
239
+ const app = new Edge();
222
240
 
223
- If your handler returns a JSX element directly, Edge will automatically render it:
241
+ app.defaults.cors.origin = ['https://app.example.com'];
224
242
 
225
- ```tsx
226
- app.get('/auto', (ctx) => <h1>Auto rendered</h1>);
243
+ app.security.hashAuthTokens = true; // SHA-256 token keys, migrate first
244
+ app.security.logSecurityEvents = true; // structured JSON events (default on)
245
+ app.security.extraHeaders = {
246
+ 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
247
+ 'Cross-Origin-Opener-Policy': 'same-origin',
248
+ 'Cross-Origin-Resource-Policy': 'same-origin',
249
+ };
227
250
  ```
228
251
 
252
+ ### OWASP 2025 Coverage
253
+
254
+ | Category | Mitigation |
255
+ |---|---|
256
+ | A01 Broken Access Control | Auth reasons, expiry enforcement, scope/role checks, strict target resolution |
257
+ | A02 Security Misconfiguration | Safe default headers, strict CORS allowlist, `Vary: Origin` |
258
+ | A03 Supply Chain | Zero deps, SRI honored on injected resources |
259
+ | A04 Crypto Failures | Optional SHA-256 token hashing |
260
+ | A05 Injection / XSS | Prototype-safe objects, JSX attribute sanitization, Trusted Types policy, response sanitizer |
261
+ | A06 Insecure Design | Numeric-sanitized rate limiter, `Retry-After` |
262
+ | A07 Auth Failures | Structured failure reasons, no swallowed errors |
263
+ | A08 Data Integrity | Prototype-safe JSON, `Set-Cookie` stripped from cache |
264
+ | A09 Logging | Structured JSON security events with 60s dedupe |
265
+ | A10 Exceptional Conditions | Fail-closed middleware, no internal leakage |
266
+
229
267
  ---
230
268
 
231
269
  ## Scheduled Tasks
232
270
 
233
- Register a scheduled handler for Cron triggers.
234
-
235
271
  ```ts
236
272
  app.scheduled(async (event, env, ctx) => {
237
273
  console.log('Cron executed:', event.cron);
238
274
  });
239
- ```
240
-
241
- Then export it in your worker:
242
275
 
243
- ```ts
244
276
  export default {
245
277
  fetch: (req, env, ctx) => app.fetch(req, env, ctx),
246
- scheduled: (event, env, ctx) => app.scheduledHandler?.(event, env, ctx)
278
+ scheduled: (event, env, ctx) => app.scheduledHandler?.(event, env, ctx),
247
279
  };
248
280
  ```
249
281
 
250
282
  ---
251
283
 
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
284
  ## Configuration
333
285
 
334
- ### `wrangler.jsonc`
335
-
336
- If you use authentication or rate limiting, you need KV namespaces. Create them first:
286
+ ### KV Namespaces
337
287
 
338
288
  ```bash
339
289
  wrangler kv namespace create AUTH_KV
340
290
  wrangler kv namespace create RATE_LIMIT_KV
341
291
  ```
342
292
 
343
- Then wire up the returned IDs in your config:
344
-
345
293
  ```jsonc
346
294
  {
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
295
  "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
- }
296
+ { "binding": "AUTH_KV", "id": "..." },
297
+ { "binding": "RATE_LIMIT_KV", "id": "..." }
298
+ ]
363
299
  }
364
300
  ```
365
301
 
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:
302
+ Rename the bindings on the instance:
397
303
 
398
304
  ```ts
399
305
  app.authKvBinding = 'CUSTOM_AUTH_KV';
@@ -402,113 +308,40 @@ app.rateLimitKvBinding = 'CUSTOM_RATE_KV';
402
308
 
403
309
  ---
404
310
 
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
311
  ## Performance
462
312
 
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
313
+ 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:
471
314
 
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.
315
+ | Framework | `/text` req/s | `/json` req/s |
316
+ |---|---|---|
317
+ | @lengkapp/edge | 503 | 500 |
318
+ | Hono | 506 | 495 |
319
+ | Hono (tiny) | 505 | 498 |
320
+ | Native Workers | 498 | 500 |
473
321
 
474
- ### Results
322
+ The security hardening adds only cheap operations to the request path: prototype-safe objects, two regex checks per JSX attribute, a sanitizer template parse per response (browser-native parser), and a throttled logger. No new async boundaries, no added dependencies, no hashing on the hot path.
475
323
 
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 |
324
+ 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.
486
325
 
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
326
+ ---
504
327
 
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.
328
+ ## Security Posture Summary
329
+
330
+ | Layer | Mechanism |
331
+ |---|---|
332
+ | HTML insertion | `sanitizeHtml` → `innerHTML` via Trusted Types policy `edge-policy` |
333
+ | Dangerous elements | `<base>`, `<meta refresh>`, `<iframe srcdoc>`, `<object>`, `<embed>`, `form[action^="javascript:"]` removed |
334
+ | Dangerous URLs | `javascript:`, `vbscript:`, `data:text/html` removed from `href`/`src`/`action`/`formaction`/`xlink:href`/`data` |
335
+ | Script execution | Nonce-gated when the page uses CSP nonces; deduplicated by `src`; inline scripts block-wrapped |
336
+ | CSRF | `X-CSRF-Token` from meta or cookie on state-changing methods |
337
+ | Cookies | `credentials: 'same-origin'`; names validated on the server |
338
+ | Server response headers | `nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy` |
339
+ | Server middleware | Fail-closed; structured security logs; strict CORS allowlist; optional token hashing |
340
+ | Prototype pollution | `Object.create(null)` + forbidden-key filtering in params, cookies, JSON, JSX |
341
+ | Build | Reserved exports/properties, `keep_quoted: "strict"`, per-bundle post-minification self-test |
509
342
 
510
343
  ---
511
344
 
512
345
  ## License
513
346
 
514
- See [LICENSE](./LICENSE). © LengkApp — Yasir Haris
347
+ See LICENSE. © LengkApp — Yasir Haris