@lengkapp/edge 0.0.23 → 0.0.24

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