@lengkapp/edge 0.0.12 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/edge-client.js +1 -1
- package/dist/edge-server.d.ts +73 -4
- package/dist/edge-server.js +2 -2
- package/package.json +1 -1
- package/readme.md +211 -52
package/dist/edge-client.js
CHANGED
package/dist/edge-server.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* @lengkapp/edge v0.0.
|
|
2
|
+
* @lengkapp/edge v0.0.13
|
|
3
3
|
* MIT License
|
|
4
4
|
*
|
|
5
5
|
* LengkApp Edge License
|
|
@@ -109,7 +109,15 @@ interface RateLimitOptions {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
interface CorsOptions {
|
|
112
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Allowed origin(s).
|
|
114
|
+
* - `'*'` (default) preserves legacy wildcard behavior.
|
|
115
|
+
* - A specific string matches that origin and enables
|
|
116
|
+
* `Access-Control-Allow-Credentials: true`.
|
|
117
|
+
* - An array is treated as a strict allowlist; the request `Origin`
|
|
118
|
+
* is reflected back only when it matches, and `Vary: Origin` is set.
|
|
119
|
+
*/
|
|
120
|
+
origin?: string | string[];
|
|
113
121
|
methods?: string;
|
|
114
122
|
headers?: string;
|
|
115
123
|
}
|
|
@@ -129,14 +137,75 @@ interface RouteOptions {
|
|
|
129
137
|
log?: boolean;
|
|
130
138
|
}
|
|
131
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Security controls for the Edge instance. All fields are optional and
|
|
142
|
+
* default to values that preserve legacy runtime behavior.
|
|
143
|
+
*/
|
|
144
|
+
interface SecurityOptions {
|
|
145
|
+
/**
|
|
146
|
+
* When `true`, tokens are SHA-256 hashed (hex, prefixed with `tok:`) before
|
|
147
|
+
* being used as the KV key in `_processAuth`. Enable only after migrating
|
|
148
|
+
* existing tokens to hashed keys.
|
|
149
|
+
*
|
|
150
|
+
* @default false
|
|
151
|
+
*/
|
|
152
|
+
hashAuthTokens?: boolean;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* When `true`, structured JSON security events (auth failures, rate limit
|
|
156
|
+
* hits, handler errors, etc.) are emitted to `console.log`. Events are
|
|
157
|
+
* deduplicated per `(event, ip, path)` over a 60-second window to avoid
|
|
158
|
+
* log flooding.
|
|
159
|
+
*
|
|
160
|
+
* @default true
|
|
161
|
+
*/
|
|
162
|
+
logSecurityEvents?: boolean;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Additional response headers applied to every `Context` before the
|
|
166
|
+
* handler runs. Useful for HSTS, COOP/COEP, CORP, etc.
|
|
167
|
+
*
|
|
168
|
+
* @default null
|
|
169
|
+
*/
|
|
170
|
+
extraHeaders?: Record<string, string> | null;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Reserved for future IP-trust configuration. Currently unused.
|
|
174
|
+
*
|
|
175
|
+
* @default null
|
|
176
|
+
*/
|
|
177
|
+
trustedProxies?: string[] | null;
|
|
178
|
+
}
|
|
179
|
+
|
|
132
180
|
type RouteHandler = (ctx: Context) => Response | JSXElement | void | Promise<Response | JSXElement | void>;
|
|
133
181
|
|
|
182
|
+
type ScheduledHandler = (
|
|
183
|
+
controller: ScheduledController,
|
|
184
|
+
env: any,
|
|
185
|
+
ctx: ExecutionContext
|
|
186
|
+
) => void | Promise<void>;
|
|
187
|
+
|
|
134
188
|
export class Edge {
|
|
135
189
|
constructor();
|
|
136
190
|
|
|
137
191
|
authKvBinding: string;
|
|
138
192
|
rateLimitKvBinding: string;
|
|
139
|
-
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Defaults applied to every route unless overridden at registration time.
|
|
196
|
+
* `cors.origin` accepts a string or string[] — see `CorsOptions`.
|
|
197
|
+
*/
|
|
198
|
+
defaults: {
|
|
199
|
+
cors: CorsOptions;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
scheduledHandler: ScheduledHandler | null;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Security controls. Mutate after construction to opt into hardened
|
|
206
|
+
* behavior; see `SecurityOptions`.
|
|
207
|
+
*/
|
|
208
|
+
security: Required<SecurityOptions>;
|
|
140
209
|
|
|
141
210
|
get(path: string, handler: RouteHandler): void;
|
|
142
211
|
get(path: string, options: boolean | RouteOptions, handler: RouteHandler): void;
|
|
@@ -159,7 +228,7 @@ export class Edge {
|
|
|
159
228
|
head(path: string, handler: RouteHandler): void;
|
|
160
229
|
head(path: string, options: boolean | RouteOptions, handler: RouteHandler): void;
|
|
161
230
|
|
|
162
|
-
scheduled(handler:
|
|
231
|
+
scheduled(handler: ScheduledHandler): void;
|
|
163
232
|
|
|
164
233
|
fetch(request: Request, env: any, executionCtx: ExecutionContext): Promise<Response>;
|
|
165
234
|
}
|
package/dist/edge-server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* @lengkapp/edge v0.0.
|
|
2
|
+
* @lengkapp/edge v0.0.13
|
|
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})},s=(r,s,i,c)=>{if(s&&"object"==typeof s||"function"==typeof s)for(let a of n(s))o.call(r,a)||a===i||t(r,a,{get:()=>s[a],enumerable:!(c=e(s,a))||c.enumerable});return r},i=e=>s(t({},"__esModule",{value:!0}),e),c={};function a(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(c,{Edge:()=>Edge,Fragment:()=>Fragment,jsx:()=>jsx,renderToString:()=>renderToString}),module.exports=i(c);var u=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.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){const e=decodeURIComponent(n.slice(0,o)),r=decodeURIComponent(n.slice(o+1));t[e]=r}}this.o=t}return this.o}getCookie(t){return this.i()[t]??null}get query(){return this.url.searchParams}setCookie(t,e,n={}){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"),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"),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"),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 f=new Set(["area","base","br","col","embed","hr","img","input","link","meta","param","source","track","wbr"]),h=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"]),d=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"]),p={"&":"&","<":"<",">":">",'"':""","'":"'"},m=/[&<>"']/g;function w(t){return m.lastIndex=0,m.test(t)?t.replace(m,t=>p[t]):t}var y=new Map,g=2e3;function T(t){const e=y.get(t);if(void 0!==e)return e;const n=t.replace(/([A-Z])/g,"-$1").toLowerCase().replace(/^-/,"");return y.size>=g&&y.clear(),y.set(t,n),n}function b(t){if(!t||"object"!=typeof t)return"";const e=Object.entries(t);return 0===e.length?"":e.map(([t,e])=>{let n=T(t);return"number"!=typeof e||d.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 w(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 w(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}));const r=f.has(e),s=[];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"),h.has(o))!0===e&&s.push(` ${o}`);else if(!0!==e){if("style"===o&&"object"==typeof e){const t=b(e);t&&s.push(` style="${w(t)}"`);continue}s.push(` ${o}="${w(String(e))}"`)}else s.push(` ${o}="true"`)}const i=s.join("");let c="";if(n&&n.dangerouslySetInnerHTML&&null!=n.dangerouslySetInnerHTML.__html)c=n.dangerouslySetInnerHTML.__html;else{const t=[];for(let e=0;e<o.length;e++)t.push(renderToString(o[e]));c=t.join("")}return r?`<${e}${i}/>`:`<${e}${i}>${c}</${e}>`}var C=class{constructor(){this.children=null,this.paramChild=null,this.handler=null}},$=class{constructor(){this.root=new C}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))r.push(n.slice(1)),o.paramChild||(o.paramChild=new C),o=o.paramChild;else{o.children||(o.children=new Map);let t=o.children.get(n);t||(t=new C,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:s}=n,i={};for(let t=0;t<s.length;t++)i[s[t]]=e[t];return{handler:o,options:r,params:i}}u(t,e,n,o){if(n===e.length)return t.handler;const r=e[n];if(t.children){const s=t.children.get(r);if(s){const t=this.u(s,e,n+1,o);if(t)return t}}if(t.paramChild){o.push(r);const s=this.u(t.paramChild,e,n+1,o);if(s)return s;o.pop()}return null}},E=["GET","POST","PUT","DELETE","PATCH","OPTIONS","HEAD"],Edge=class{constructor(){this.staticRoutes=new Map;for(const t of E)this.staticRoutes.set(t,new Map);this.dynamicTries={};for(const t of E)this.dynamicTries[t]=new $;this.authKvBinding="AUTH_KV",this.rateLimitKvBinding="RATE_LIMIT_KV",this.defaults={cors:{origin:"*",methods:"GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD"}},this.scheduledHandler=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{const n=a(e);this.dynamicTries[t].add(n,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}async p(t,e){if(!e)return!0;const n=t.getCookie("auth_token")||t.req.headers.get("Authorization")?.replace(/^Bearer\s+/i,"");if(!n)return!1;const o=t.env[this.authKvBinding];if(!o)return!1;const r=await o.get(n);if(!r)return!1;if("object"==typeof e)try{const t=JSON.parse(r);if(e.role&&t.role!==e.role)return!1;if(e.scopes){const n=t.scopes||[];if(!e.scopes.every(t=>n.includes(t)))return!1}}catch{}return!0}async m(t,e){if(!e)return!0;const n=!0===e?{}:e,{max:o=100,window:r=60}=n,s=`rl:${t.req.headers.get("CF-Connecting-IP")||"unknown"}`,i=t.env[this.rateLimitKvBinding];if(!i)return!0;let c=await i.get(s,"json")||0;return!(c>=o||(c++,await i.put(s,JSON.stringify(c),{expirationTtl:r}),0))}T(t,e){if(!e)return;const n="object"==typeof e?e:this.defaults.cors;t.headers.set("Access-Control-Allow-Origin",n.origin||"*"),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"),t.headers.set("Access-Control-Max-Age","86400")}C(t,e){e&&console.log(`${t.req.method} ${t.req.url} - ${t.status}`)}$(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)),s=new Headers(e.headers);return s.set("Content-Encoding",o),s.set("Vary","Accept-Encoding"),s.delete("Content-Length"),new Response(r,{status:e.status,statusText:e.statusText,headers:s})}async S(t,e){if(!e)return!0;if("function"==typeof e)try{return!!await e(t)}catch{return!1}return!0}async A(t){const e=caches.default;return await e.match(t)||null}async O(t,e,n){if(!n)return;const o=!0===n?{}:n,{ttl:r=3600,staleWhileRevalidate:s=0}=o,i=caches.default,c=e.clone();c.headers.set("Cache-Control",`max-age=${r}${s>0?`, stale-while-revalidate=${s}`:""}`),c.headers.delete("Set-Cookie"),await i.put(t,c)}async fetch(t,e,n){const o=new URL(t.url),r=l(o.pathname),s=t.method,i=this.staticRoutes.get(s);if(i){const s=i.get(r);if(s)return this._(s.handler,s.options,t,e,n,{},o)}const c=this.dynamicTries[s];if(c){const s=a(r),i=c.match(s);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,s,i){const c=new u(n,o,r,s,i);if(!await this.S(c,e.validate))return c.text("Validation failed",400);if(!await this.p(c,e.auth))return c.text("Unauthorized",401);if(!await this.m(c,e.rateLimit))return c.text("Too Many Requests",429);let a,l=null;if(e.cache&&"GET"===n.method&&(l=await this.A(n),l))return this.T(c,e.cors),this.C(c,e.log),l;this.T(c,e.cors);try{const e=await t(c);a=e&&e.__isJSX?c.html(renderToString(e)):e instanceof Response?e:c.text("OK"),c.status=a.status}catch(t){console.error(t),a=c.text("Internal Server Error",500),c.status=500}return e.cache&&"GET"===n.method&&200===a.status&&c.executionCtx.waitUntil(this.O(n,a.clone(),e.cache)),e.compress&&(a=this.$(n,a)),this.C(c,e.log),a}};
|
|
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("Referrer-Policy","strict-origin-when-cross-origin"),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"]),w=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={"&":"&","<":"<",">":">",'"':""","'":"'"},m=/[&<>"']/g;function b(t){return m.lastIndex=0,m.test(t)?t.replace(m,t=>g[t]):t}var A=new Map,_=2e3;function T(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>=_&&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(t);return"number"!=typeof e||w.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}},$=["GET","POST","PUT","DELETE","PATCH","OPTIONS","HEAD"],S=new Map;function x(t,e,n){const o=`${t}|${e||""}|${n||""}`,r=Date.now(),i=S.get(o);if(i&&r-i<6e4)return!1;if(S.set(o,r),S.size>5e3)for(const[t,e]of S)r-e>6e4&&S.delete(t);return!0}var Edge=class{constructor(){this.staticRoutes=new Map;for(const t of $)this.staticRoutes.set(t,new Map);this.dynamicTries={};for(const t of $)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{const n=c(e);this.dynamicTries[t].add(n,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(!x(e,o,r))return;const i={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};console.log(JSON.stringify(i))}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(t){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,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"),t.headers.set("Access-Control-Max-Age","86400")}T(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){const e=caches.default;return await e.match(t)||null}async $(t,e,n){if(!n)return;const o=!0===n?{}:n,{ttl:r=3600,staleWhileRevalidate:i=0}=o,s=caches.default,a=e.clone();a.headers.set("Cache-Control",`max-age=${r}${i>0?`, stale-while-revalidate=${i}`:""}`),a.headers.delete("Set-Cookie"),await s.put(t,a)}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.S(i.handler,i.options,t,e,n,Object.create(null),o)}const a=this.dynamicTries[i];if(a){const i=c(r),s=a.match(i);if(s)return this.S(s.handler,s.options,t,e,n,s.params,o)}return new Response("Not Found",{status:404})}async S(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)){const t=c&&c.reason||"denied";return this.p(a,"auth_failure",t),a.text("Unauthorized",401)}let u=!1;try{u=await this.A(a,e.rateLimit)}catch(t){this.p(a,"rate_limit_error",t&&t.message),u=!1}if(!u)return this.p(a,"rate_limit_exceeded",null),a.text("Too Many Requests",429,{"Retry-After":"60"});let f,h=null;if(e.cache&&"GET"===n.method&&(h=await this.O(n),h))return this._(a,e.cors),this.T(a,e.log),h;this._(a,e.cors);try{const e=await t(a);f=e&&e.__isJSX?a.html(renderToString(e)):e instanceof Response?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.$(n,f.clone(),e.cache)),e.compress&&(f=this.C(n,f)),this.T(a,e.log),f}};
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -4,19 +4,35 @@ A minimal, high-performance framework for Cloudflare Workers with built-in serve
|
|
|
4
4
|
|
|
5
5
|
Inspired by [Hono](https://hono.dev), @lengkapp/edge aims for the same class of performance while shipping with **zero runtime dependencies**. See [Performance](#performance) for a local benchmark against Hono, `hono/tiny`, and a plain native Workers handler.
|
|
6
6
|
|
|
7
|
+
> **Security:** This release hardens both the server and the client against the OWASP Top 10:2025 and ASVS 5.0 Layer 1 controls. See [Security](#security-controls) for the full breakdown of what's protected and how to opt into the strongest settings.
|
|
8
|
+
|
|
7
9
|
---
|
|
8
10
|
|
|
9
11
|
## Features
|
|
10
12
|
|
|
13
|
+
### Server
|
|
11
14
|
- **Trie-based routing** – static & dynamic routes (`/users/:id`) with fast lookups.
|
|
12
15
|
- **JSX support** – render JSX components to HTML without a build step (using `jsx` and `renderToString`).
|
|
13
16
|
- **Middleware options** – authentication, rate limiting, CORS, logging, caching, compression, and custom validation.
|
|
14
17
|
- **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
18
|
- **Scheduled tasks** – built-in support for Cron triggers.
|
|
18
19
|
- **Zero dependencies** – lightweight and fast.
|
|
19
20
|
|
|
21
|
+
### Client
|
|
22
|
+
- **Declarative partial updates** – enhance HTML with `_get`, `_post`, `_target`, `_trigger` attributes to fetch and replace content dynamically.
|
|
23
|
+
- **Skeleton loaders & retry UI** – built-in loading and error states.
|
|
24
|
+
- **Safe DOM updates** – server responses are parsed with `DOMParser` and inserted via `importNode`; scripts are stripped and never re-executed.
|
|
25
|
+
- **CSRF-aware** – state-changing requests automatically send `X-CSRF-Token` from a `<meta name="csrf-token">` tag or an `XSRF-TOKEN` cookie.
|
|
26
|
+
- **Trusted Types aware** – plays nicely with a `require-trusted-types-for 'script'` CSP.
|
|
27
|
+
|
|
28
|
+
### Security (both)
|
|
29
|
+
- **Prototype-pollution safe** – route params, cookies, JSON payloads, and JSX attributes all use forbidden-key filtering.
|
|
30
|
+
- **XSS-hardened JSX** – event handlers (`on*`), malformed tag/attr names, and `javascript:`/`data:text/html` URIs are blocked.
|
|
31
|
+
- **Structured security logging** – auth failures, rate-limit hits, and handler errors emit throttled JSON events.
|
|
32
|
+
- **Fail-closed middleware** – validation, auth, and rate-limit errors deny by default.
|
|
33
|
+
- **Opt-in token hashing** – SHA-256 hash auth tokens before KV lookup.
|
|
34
|
+
- **Strict CORS allowlist** – supports per-origin reflection with `Vary: Origin` and credentials.
|
|
35
|
+
|
|
20
36
|
---
|
|
21
37
|
|
|
22
38
|
## Installation
|
|
@@ -25,11 +41,9 @@ Inspired by [Hono](https://hono.dev), @lengkapp/edge aims for the same class of
|
|
|
25
41
|
npm install @lengkapp/edge
|
|
26
42
|
```
|
|
27
43
|
|
|
28
|
-
---
|
|
29
|
-
|
|
30
44
|
## Quick Start
|
|
31
45
|
|
|
32
|
-
**1. Create a worker script (`worker.js` or `src/index.ts`)
|
|
46
|
+
**1. Create a worker script** (`worker.js` or `src/index.ts`):
|
|
33
47
|
|
|
34
48
|
```ts
|
|
35
49
|
import { Edge, renderToString } from '@lengkapp/edge';
|
|
@@ -49,7 +63,7 @@ export default {
|
|
|
49
63
|
|
|
50
64
|
**2. Create `wrangler.jsonc`:**
|
|
51
65
|
|
|
52
|
-
Cloudflare recommends `wrangler.jsonc` for new projects (TOML is still supported — see [Configuration](#configuration)).
|
|
66
|
+
> Cloudflare recommends `wrangler.jsonc` for new projects (TOML is still supported — see [Configuration](#configuration)).
|
|
53
67
|
|
|
54
68
|
```jsonc
|
|
55
69
|
{
|
|
@@ -105,16 +119,16 @@ Supported methods: `get`, `post`, `put`, `delete`, `patch`, `options`, `head`.
|
|
|
105
119
|
Each handler receives a `Context` object (`ctx`) with the following members:
|
|
106
120
|
|
|
107
121
|
| Property / Method | Description |
|
|
108
|
-
|
|
122
|
+
|---|---|
|
|
109
123
|
| `ctx.req` | The original `Request` object |
|
|
110
124
|
| `ctx.env` | The environment bindings (KV, secrets, etc.) |
|
|
111
125
|
| `ctx.executionCtx` | The `ExecutionContext` (for `waitUntil`) |
|
|
112
|
-
| `ctx.params` | Route parameters (
|
|
113
|
-
| `ctx.status` | HTTP status code (default
|
|
126
|
+
| `ctx.params` | Route parameters (prototype-safe object) |
|
|
127
|
+
| `ctx.status` | HTTP status code (default 200) |
|
|
114
128
|
| `ctx.headers` | `Headers` object for the response |
|
|
115
129
|
| `ctx.query` | `URLSearchParams` (lazy) |
|
|
116
130
|
| `ctx.getCookie(name)` | Get a cookie value |
|
|
117
|
-
| `ctx.setCookie(name, value, options)` | Set a cookie |
|
|
131
|
+
| `ctx.setCookie(name, value, options)` | Set a cookie (name is validated) |
|
|
118
132
|
| `ctx.deleteCookie(name, options)` | Delete a cookie (expires immediately) |
|
|
119
133
|
| `ctx.text(data, status?, headers?)` | Return plain text |
|
|
120
134
|
| `ctx.json(data, status?, headers?)` | Return JSON |
|
|
@@ -122,6 +136,13 @@ Each handler receives a `Context` object (`ctx`) with the following members:
|
|
|
122
136
|
|
|
123
137
|
Cookie options: `path`, `domain`, `maxAge`, `expires`, `secure`, `httpOnly`, `sameSite`.
|
|
124
138
|
|
|
139
|
+
Every response automatically includes these safe headers:
|
|
140
|
+
|
|
141
|
+
- `X-Content-Type-Options: nosniff`
|
|
142
|
+
- `Referrer-Policy: strict-origin-when-cross-origin`
|
|
143
|
+
|
|
144
|
+
You can add more via `app.security.extraHeaders` (see [Security Controls](#security-controls)).
|
|
145
|
+
|
|
125
146
|
### Returning Responses
|
|
126
147
|
|
|
127
148
|
You can return a `Response` object, a string (becomes text), or an object (automatically JSON).
|
|
@@ -153,9 +174,11 @@ app.get('/scoped', { auth: { scopes: ['read', 'write'] } }, handler);
|
|
|
153
174
|
|
|
154
175
|
The client must send the token via `Authorization: Bearer <token>` or a cookie named `auth_token`.
|
|
155
176
|
|
|
177
|
+
If the KV payload includes an `exp` field (Unix seconds), expired tokens are rejected with a `401`. Malformed JSON payloads are rejected with `401` and logged as a security event.
|
|
178
|
+
|
|
156
179
|
#### Rate Limiting (`rateLimit`)
|
|
157
180
|
|
|
158
|
-
Requires a `RATE_LIMIT_KV` binding.
|
|
181
|
+
Requires a `RATE_LIMIT_KV` binding. Rate-limit responses include a `Retry-After: 60` header.
|
|
159
182
|
|
|
160
183
|
```ts
|
|
161
184
|
app.get('/limited', { rateLimit: { max: 100, window: 60 } }, handler);
|
|
@@ -171,26 +194,36 @@ app.get('/cached', { cache: { ttl: 60, staleWhileRevalidate: 30 } }, handler);
|
|
|
171
194
|
|
|
172
195
|
#### CORS (`cors`)
|
|
173
196
|
|
|
174
|
-
Enables CORS headers.
|
|
197
|
+
Enables CORS headers. The default (`origin: '*'`) preserves legacy behavior. For production, use a strict allowlist:
|
|
175
198
|
|
|
176
199
|
```ts
|
|
200
|
+
// Legacy wildcard (default)
|
|
177
201
|
app.get('/api', { cors: true }, handler);
|
|
178
202
|
|
|
179
|
-
//
|
|
203
|
+
// Single origin
|
|
180
204
|
app.get('/api', { cors: { origin: 'https://example.com', methods: 'GET,POST' } }, handler);
|
|
205
|
+
|
|
206
|
+
// Strict allowlist — reflects matching Origin, sets Vary: Origin and credentials
|
|
207
|
+
app.get('/api', {
|
|
208
|
+
cors: {
|
|
209
|
+
origin: ['https://app.example.com', 'https://admin.example.com'],
|
|
210
|
+
methods: 'GET,POST,PUT,DELETE',
|
|
211
|
+
headers: 'Content-Type, Authorization, X-CSRF-Token',
|
|
212
|
+
},
|
|
213
|
+
}, handler);
|
|
181
214
|
```
|
|
182
215
|
|
|
183
216
|
#### Logging (`log`)
|
|
184
217
|
|
|
185
|
-
Logs method, URL, and status to the console.
|
|
218
|
+
Logs method, URL, and status to the console. Separately, security events (auth failures, rate-limit hits, handler errors) are logged as structured JSON when `app.security.logSecurityEvents` is `true` (the default). Events are deduplicated per `(event, IP, path)` over a 60-second window to prevent log flooding.
|
|
186
219
|
|
|
187
220
|
#### Compression (`compress`)
|
|
188
221
|
|
|
189
|
-
Compresses the response using gzip/deflate
|
|
222
|
+
Compresses the response using gzip/deflate if the client supports it.
|
|
190
223
|
|
|
191
224
|
#### Custom Validation (`validate`)
|
|
192
225
|
|
|
193
|
-
A function returning `true`/`false` (or a `Promise`) to allow/deny the request.
|
|
226
|
+
A function returning `true`/`false` (or a `Promise`) to allow/deny the request. Validation errors fail closed.
|
|
194
227
|
|
|
195
228
|
```ts
|
|
196
229
|
app.post('/submit', {
|
|
@@ -200,6 +233,48 @@ app.post('/submit', {
|
|
|
200
233
|
|
|
201
234
|
---
|
|
202
235
|
|
|
236
|
+
## Security Controls
|
|
237
|
+
|
|
238
|
+
All controls are optional and default to behavior that preserves previous releases. Enable them after construction:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
const app = new Edge();
|
|
242
|
+
|
|
243
|
+
// 1. Strict CORS allowlist (replaces the default '*')
|
|
244
|
+
app.defaults.cors.origin = ['https://app.example.com', 'https://admin.example.com'];
|
|
245
|
+
|
|
246
|
+
// 2. Hash auth tokens with SHA-256 before KV lookup.
|
|
247
|
+
// Only enable after migrating existing tokens to `tok:<sha256-hex>` keys.
|
|
248
|
+
app.security.hashAuthTokens = true;
|
|
249
|
+
|
|
250
|
+
// 3. Add HSTS, COOP, COEP, CORP at the edge.
|
|
251
|
+
app.security.extraHeaders = {
|
|
252
|
+
'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
|
|
253
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
254
|
+
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// 4. Disable structured security logs if you ship logs elsewhere.
|
|
258
|
+
// app.security.logSecurityEvents = false;
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### What's Protected
|
|
262
|
+
|
|
263
|
+
| OWASP 2025 Category | Protection in @lengkapp/edge |
|
|
264
|
+
|---|---|
|
|
265
|
+
| A01: Broken Access Control | Structured auth result with reasons; expiry enforcement when `exp` is present; role/scope checks; strict target resolution on the client. |
|
|
266
|
+
| A02: Security Misconfiguration | Safe default headers (`nosniff`, `Referrer-Policy`); strict CORS allowlist support; per-origin reflection with `Vary: Origin`. |
|
|
267
|
+
| A03: Software Supply Chain | Zero runtime dependencies; SRI attributes honored on the client for `x-js-required` / `x-css-required`. |
|
|
268
|
+
| A04: Cryptographic Failures | Optional SHA-256 token hashing before KV lookup (`app.security.hashAuthTokens`). |
|
|
269
|
+
| A05: Injection / XSS / Prototype Pollution | `Object.create(null)` for params, cookies, and JSON; forbidden-key filtering in route segments, JSON payloads, cookie names, and JSX attributes; event-handler attributes blocked; `javascript:`/`data:text/html` URIs rejected; client uses `DOMParser` + `importNode` and never executes inline scripts. |
|
|
270
|
+
| A06: Insecure Design | Numeric-sanitized rate limiter; `Retry-After` on 429; sliding counter per IP. |
|
|
271
|
+
| A07: Authentication Failures | Specific failure reasons logged; no swallowed catches in the auth path. |
|
|
272
|
+
| A08: Data Integrity | Prototype-safe JSON parsing for KV payloads; `Set-Cookie` stripped from cached responses. |
|
|
273
|
+
| A09: Logging Failures | Structured JSON security events with 60-second dedupe window. |
|
|
274
|
+
| A10: Exceptional Conditions | Validation, auth, and rate-limit middleware fail closed on errors; handler errors never leak internals. |
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
203
278
|
## JSX Support
|
|
204
279
|
|
|
205
280
|
The package includes a minimal JSX runtime. Write components as functions returning JSX.
|
|
@@ -226,6 +301,17 @@ If your handler returns a JSX element directly, Edge will automatically render i
|
|
|
226
301
|
app.get('/auto', (ctx) => <h1>Auto rendered</h1>);
|
|
227
302
|
```
|
|
228
303
|
|
|
304
|
+
### JSX Hardening
|
|
305
|
+
|
|
306
|
+
`renderToString` applies the following protections automatically:
|
|
307
|
+
|
|
308
|
+
- Tag names must match `^[A-Za-z][A-Za-z0-9-]*$` — anything else is dropped.
|
|
309
|
+
- Attribute names must match `^[A-Za-z_:][A-Za-z0-9_:.-]*$` and are rejected if they start with `on` (event handlers never serialize).
|
|
310
|
+
- `href`, `src`, `xlink:href`, `action`, and `formaction` values beginning with `javascript:`, `vbscript:`, or `data:text/html` are stripped.
|
|
311
|
+
- All string values are HTML-escaped (`&`, `<`, `>`, `"`, `'`).
|
|
312
|
+
- Prototype keys (`__proto__`, `constructor`, `prototype`) are rejected as attribute names.
|
|
313
|
+
- `dangerouslySetInnerHTML` remains available but is opt-in and never applied to attribute values.
|
|
314
|
+
|
|
229
315
|
---
|
|
230
316
|
|
|
231
317
|
## Scheduled Tasks
|
|
@@ -262,30 +348,33 @@ Then use HTML attributes to make elements fetch content asynchronously.
|
|
|
262
348
|
### Attributes
|
|
263
349
|
|
|
264
350
|
| Attribute | Description |
|
|
265
|
-
|
|
351
|
+
|---|---|
|
|
266
352
|
| `_get` | URL to fetch via GET |
|
|
267
353
|
| `_post` | URL to fetch via POST |
|
|
268
|
-
| `
|
|
354
|
+
| `_put`, `_patch`, `_delete` | Other HTTP methods |
|
|
355
|
+
| `_target` | CSS selector for the container to replace. Use `"this"` to replace the element itself. Only simple selectors (`#id`, `.class`, `tag`) are accepted for safety. |
|
|
269
356
|
| `_trigger` | Comma-separated list of events that trigger the fetch (default: `click`; `load` if `_target="this"`). Supported: `click`, `load`, `visible`, `intersect`, `submit`, etc. |
|
|
270
357
|
| `_form` | (with `_post`) ID of a form to serialize as the POST body (URL-encoded). |
|
|
271
358
|
| `_json` | (with `_post`) Comma-separated names of input fields to send as JSON. |
|
|
359
|
+
| `_skeleton` | Set to `"false"` to disable the loading skeleton. |
|
|
360
|
+
| `_retry` | Set to `"false"` to disable the retry button on error. |
|
|
272
361
|
|
|
273
362
|
### Examples
|
|
274
363
|
|
|
275
|
-
|
|
364
|
+
Load content on click:
|
|
276
365
|
|
|
277
366
|
```html
|
|
278
367
|
<button _get="/more-posts" _target="#posts" _trigger="click">Load More</button>
|
|
279
368
|
<div id="posts"></div>
|
|
280
369
|
```
|
|
281
370
|
|
|
282
|
-
|
|
371
|
+
Auto-load on page load:
|
|
283
372
|
|
|
284
373
|
```html
|
|
285
374
|
<div _get="/user-profile" _target="this"></div>
|
|
286
375
|
```
|
|
287
376
|
|
|
288
|
-
|
|
377
|
+
Post a form via AJAX:
|
|
289
378
|
|
|
290
379
|
```html
|
|
291
380
|
<form id="contact-form">
|
|
@@ -295,7 +384,7 @@ Then use HTML attributes to make elements fetch content asynchronously.
|
|
|
295
384
|
<div id="result"></div>
|
|
296
385
|
```
|
|
297
386
|
|
|
298
|
-
|
|
387
|
+
Send JSON data:
|
|
299
388
|
|
|
300
389
|
```html
|
|
301
390
|
<input name="username" />
|
|
@@ -303,7 +392,7 @@ Then use HTML attributes to make elements fetch content asynchronously.
|
|
|
303
392
|
<button _post="/login" _json="username,password" _target="#status">Login</button>
|
|
304
393
|
```
|
|
305
394
|
|
|
306
|
-
|
|
395
|
+
Load when element becomes visible (IntersectionObserver):
|
|
307
396
|
|
|
308
397
|
```html
|
|
309
398
|
<div _get="/lazy-content" _target="this" _trigger="visible"></div>
|
|
@@ -311,11 +400,40 @@ Then use HTML attributes to make elements fetch content asynchronously.
|
|
|
311
400
|
|
|
312
401
|
### Skeleton Loading
|
|
313
402
|
|
|
314
|
-
While the request is in progress, the target container is filled with a skeleton loader (
|
|
403
|
+
While the request is in progress, the target container is filled with a skeleton loader (a pulsing ring icon). On error, a "Retry" button is shown; clicking it re-runs the same request once.
|
|
404
|
+
|
|
405
|
+
### Safe DOM Updates
|
|
406
|
+
|
|
407
|
+
Server responses are handled as follows:
|
|
408
|
+
|
|
409
|
+
- Fetched with `credentials: 'same-origin'` and `X-Requested-With: XMLHttpRequest`.
|
|
410
|
+
- `Content-Type` is validated — only `text/html` and `text/plain` are accepted.
|
|
411
|
+
- The HTML is parsed in a detached `DOMParser` document.
|
|
412
|
+
- All `<script>` elements are removed before insertion.
|
|
413
|
+
- Nodes are imported with `document.importNode()` — never `innerHTML`.
|
|
414
|
+
- Any new AJAX-enabled elements in the response are bound to the same delegation system.
|
|
415
|
+
|
|
416
|
+
### CSRF Protection
|
|
417
|
+
|
|
418
|
+
For state-changing methods (POST, PUT, PATCH, DELETE), the client reads a CSRF token from:
|
|
419
|
+
|
|
420
|
+
1. `<meta name="csrf-token" content="...">` (preferred), or
|
|
421
|
+
2. The `XSRF-TOKEN` cookie.
|
|
422
|
+
|
|
423
|
+
The token is sent as `X-CSRF-Token` on the request. Your server handler can verify it:
|
|
424
|
+
|
|
425
|
+
```ts
|
|
426
|
+
app.post('/submit', {
|
|
427
|
+
validate: (ctx) => {
|
|
428
|
+
const sent = ctx.req.headers.get('X-CSRF-Token');
|
|
429
|
+
return sent && sent === ctx.getCookie('csrf_token');
|
|
430
|
+
}
|
|
431
|
+
}, handler);
|
|
432
|
+
```
|
|
315
433
|
|
|
316
434
|
### Resource Injection
|
|
317
435
|
|
|
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.
|
|
436
|
+
If the server response includes headers `x-css-required` or `x-js-required`, the client will automatically inject those resources (once per URL) into the page. Each URL can optionally include a matching `x-css-integrity` or `x-js-integrity` header containing a Subresource Integrity hash.
|
|
319
437
|
|
|
320
438
|
Example server route:
|
|
321
439
|
|
|
@@ -323,15 +441,21 @@ Example server route:
|
|
|
323
441
|
app.get('/widget', (ctx) => {
|
|
324
442
|
ctx.headers.set('x-css-required', '["/widget.css"]');
|
|
325
443
|
ctx.headers.set('x-js-required', '["/widget.js"]');
|
|
444
|
+
// Optional SRI — applies to every URL in the corresponding list
|
|
445
|
+
ctx.headers.set('x-js-integrity', 'sha384-...');
|
|
326
446
|
return ctx.html('<div class="widget">...</div>');
|
|
327
447
|
});
|
|
328
448
|
```
|
|
329
449
|
|
|
450
|
+
### Trusted Types
|
|
451
|
+
|
|
452
|
+
If your page enforces `require-trusted-types-for 'script'`, the client detects `window.trustedTypes` and creates an `aj-policy`. Only hardcoded UI strings (skeleton, retry) pass through the policy; server content never does.
|
|
453
|
+
|
|
330
454
|
---
|
|
331
455
|
|
|
332
456
|
## Configuration
|
|
333
457
|
|
|
334
|
-
###
|
|
458
|
+
### wrangler.jsonc
|
|
335
459
|
|
|
336
460
|
If you use authentication or rate limiting, you need KV namespaces. Create them first:
|
|
337
461
|
|
|
@@ -389,7 +513,7 @@ crons = ["*/5 * * * *"]
|
|
|
389
513
|
|
|
390
514
|
</details>
|
|
391
515
|
|
|
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/).
|
|
516
|
+
> Wrangler supports both `wrangler.jsonc` and `wrangler.toml` — they configure the same fields, just in different syntax. Don't keep both in the same project. Keep `compatibility_date` current (Cloudflare recommends staying within the last 30 days); see the [compatibility dates docs](https://developers.cloudflare.com/workers/configuration/compatibility-dates/).
|
|
393
517
|
|
|
394
518
|
### Environment Variables
|
|
395
519
|
|
|
@@ -400,6 +524,23 @@ app.authKvBinding = 'CUSTOM_AUTH_KV';
|
|
|
400
524
|
app.rateLimitKvBinding = 'CUSTOM_RATE_KV';
|
|
401
525
|
```
|
|
402
526
|
|
|
527
|
+
### Recommended CSP
|
|
528
|
+
|
|
529
|
+
The client and JSX runtime are compatible with a strict Content Security Policy. A reasonable starting point:
|
|
530
|
+
|
|
531
|
+
```text
|
|
532
|
+
Content-Security-Policy:
|
|
533
|
+
default-src 'self';
|
|
534
|
+
script-src 'self';
|
|
535
|
+
style-src 'self';
|
|
536
|
+
img-src 'self' data:;
|
|
537
|
+
require-trusted-types-for 'script';
|
|
538
|
+
frame-ancestors 'none';
|
|
539
|
+
base-uri 'self';
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
Set this via `app.security.extraHeaders` or at your edge/CDN layer.
|
|
543
|
+
|
|
403
544
|
---
|
|
404
545
|
|
|
405
546
|
## Full Example
|
|
@@ -410,6 +551,16 @@ import { HomePage } from './Page.jsx';
|
|
|
410
551
|
|
|
411
552
|
const app = new Edge();
|
|
412
553
|
|
|
554
|
+
// Strict CORS allowlist for production
|
|
555
|
+
app.defaults.cors.origin = ['https://app.example.com'];
|
|
556
|
+
|
|
557
|
+
// HSTS + isolation headers
|
|
558
|
+
app.security.extraHeaders = {
|
|
559
|
+
'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
|
|
560
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
561
|
+
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
562
|
+
};
|
|
563
|
+
|
|
413
564
|
// Basic text
|
|
414
565
|
app.get('/', (ctx) => ctx.text('Hello from Edge!'));
|
|
415
566
|
|
|
@@ -436,7 +587,7 @@ app.get('/cached', { cache: { ttl: 60 } }, (ctx) => ctx.text('Cached'));
|
|
|
436
587
|
|
|
437
588
|
// Cookies
|
|
438
589
|
app.get('/set-cookie', (ctx) => {
|
|
439
|
-
ctx.setCookie('session', 'abc123', { httpOnly: true, path: '/' });
|
|
590
|
+
ctx.setCookie('session', 'abc123', { httpOnly: true, secure: true, sameSite: 'Lax', path: '/' });
|
|
440
591
|
return ctx.text('Cookie set');
|
|
441
592
|
});
|
|
442
593
|
|
|
@@ -460,52 +611,60 @@ export default {
|
|
|
460
611
|
|
|
461
612
|
## Performance
|
|
462
613
|
|
|
463
|
-
@lengkapp/edge is inspired by
|
|
614
|
+
@lengkapp/edge is inspired by Hono — one of the fastest Workers frameworks around — but ships with zero dependencies. A quick local benchmark shows it holding its own against both Hono builds (`hono` and `hono/tiny`) and a plain native `fetch` handler with no framework at all.
|
|
464
615
|
|
|
465
616
|
### Test setup
|
|
466
617
|
|
|
467
|
-
- **Tool:**
|
|
618
|
+
- **Tool:** autocannon — 10 connections, 10s run per route
|
|
468
619
|
- **Target:** local `wrangler dev` server
|
|
469
620
|
- **Routes:** `GET /text` (plain text) and `GET /json` (JSON)
|
|
470
|
-
- **Contenders:**
|
|
621
|
+
- **Contenders:** @lengkapp/edge, hono, hono/tiny, and a native Workers handler with no framework
|
|
471
622
|
|
|
472
623
|
> This is a single local run, not a formal benchmark suite — treat the numbers as directional. Latency stdev was ±3–4 ms across all four, so differences smaller than that are within noise.
|
|
473
624
|
|
|
474
625
|
### Results
|
|
475
626
|
|
|
476
627
|
| Framework | Route | Avg Req/sec | Avg Latency | Requests | Data Read |
|
|
477
|
-
|
|
478
|
-
|
|
|
479
|
-
|
|
|
480
|
-
| Hono | `/text` |
|
|
628
|
+
|---|---|---|---|---|---|
|
|
629
|
+
| @lengkapp/edge | `/text` | 503.1 | 19.38 ms | 5,000 / 10.03s | 1.38 MB |
|
|
630
|
+
| @lengkapp/edge | `/json` | 500.4 | 19.47 ms | 5,000 / 10.03s | 1.42 MB |
|
|
631
|
+
| Hono | `/text` | 506.0 | 19.27 ms | 5,000 / 10.03s | 455 kB |
|
|
481
632
|
| Hono | `/json` | 495.2 | 19.69 ms | 5,000 / 10.03s | 426 kB |
|
|
482
633
|
| Hono (`hono/tiny`) | `/text` | 505.5 | 19.28 ms | 5,000 / 10.03s | 455 kB |
|
|
483
634
|
| Hono (`hono/tiny`) | `/json` | 497.9 | 19.57 ms | 5,000 / 10.03s | 428 kB |
|
|
484
635
|
| Native Workers (no framework) | `/text` | 498.4 | 19.55 ms | 5,000 / 10.02s | 379 kB |
|
|
485
636
|
| Native Workers (no framework) | `/json` | 499.7 | 19.51 ms | 5,000 / 10.03s | 430 kB |
|
|
486
637
|
|
|
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
638
|
### Takeaways
|
|
504
639
|
|
|
505
640
|
- On `/json`, @lengkapp/edge posted the highest average throughput of the four, ahead of both Hono builds and the native handler.
|
|
506
641
|
- On `/text`, it landed within ~1% of `hono`/`hono/tiny` and ahead of the native baseline — effectively a tie once you account for run-to-run variance.
|
|
507
|
-
- All four sit in the same performance tier; the practical difference is that @lengkapp/edge gets there with
|
|
642
|
+
- All four sit in the same performance tier; the practical difference is that @lengkapp/edge gets there with zero runtime dependencies, matching `hono/tiny`'s footprint without having to choose a "tiny" build.
|
|
508
643
|
- @lengkapp/edge read more bytes per request than the other three in this run — worth profiling if minimal payload size matters for your use case.
|
|
644
|
+
- The 2025 security hardening adds only cheap operations to the request path: prototype-safe objects (`Object.create(null)`), two regex checks per JSX attribute, and a throttled logger that skips duplicate events within 60 seconds. No new async boundaries, no hashing on the hot path (`hashAuthTokens` defaults to `false`), and no added dependencies.
|
|
645
|
+
|
|
646
|
+
---
|
|
647
|
+
|
|
648
|
+
## Migration Notes
|
|
649
|
+
|
|
650
|
+
### Upgrading from ≤ 0.x
|
|
651
|
+
|
|
652
|
+
The security release is backward-compatible. Existing apps continue to work without code changes. New defaults:
|
|
653
|
+
|
|
654
|
+
- Every response now includes `X-Content-Type-Options: nosniff` and `Referrer-Policy: strict-origin-when-cross-origin`.
|
|
655
|
+
- Rate-limited responses include `Retry-After: 60`.
|
|
656
|
+
- Route params, cookie stores, and JSON payloads use prototype-safe objects. If you were relying on `ctx.params.hasOwnProperty(...)`, use `Object.prototype.hasOwnProperty.call(ctx.params, ...)` or `'x' in ctx.params`.
|
|
657
|
+
- The JSX renderer now drops `on*` attributes. Handlers relying on server-rendered inline event handlers must be moved to the client.
|
|
658
|
+
- Security events are logged as JSON. To disable: `app.security.logSecurityEvents = false`.
|
|
659
|
+
|
|
660
|
+
### Client upgrade
|
|
661
|
+
|
|
662
|
+
If you were using the old client that executed `<script>` tags in responses, replace those scripts with either:
|
|
663
|
+
|
|
664
|
+
- DOM-based initialization (event delegation, `IntersectionObserver`, etc.), or
|
|
665
|
+
- An explicit `<script src="...">` injected via `x-js-required` on the response.
|
|
666
|
+
|
|
667
|
+
The new client refuses to run inline scripts for security reasons and will not execute `<script>` elements found in response bodies.
|
|
509
668
|
|
|
510
669
|
---
|
|
511
670
|
|