@lengkapp/edge 0.0.17 → 0.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/edge-client.js +1 -1
- package/dist/edge-server.d.ts +115 -76
- package/dist/edge-server.js +2 -2
- package/package.json +1 -1
- package/readme.md +193 -360
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.19
|
|
3
3
|
* MIT License
|
|
4
4
|
*
|
|
5
5
|
* LengkApp Edge License
|
|
@@ -30,112 +30,151 @@
|
|
|
30
30
|
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
31
31
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
32
32
|
*/
|
|
33
|
-
|
|
33
|
+
// edge-server.d.ts
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
export const Fragment: unique symbol;
|
|
36
|
+
|
|
37
|
+
export interface JSXNode {
|
|
38
|
+
type: unknown;
|
|
39
|
+
props: Record<string, any>;
|
|
40
|
+
children: any[];
|
|
41
|
+
__isJSX: true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function jsx(type: unknown, props: any, ...children: any[]): JSXNode;
|
|
45
|
+
export function renderToString(node: any): string;
|
|
46
|
+
|
|
47
|
+
/* ---------------- Context ---------------- */
|
|
48
|
+
|
|
49
|
+
export interface CookieOptions {
|
|
50
|
+
path?: string;
|
|
51
|
+
domain?: string;
|
|
52
|
+
maxAge?: number;
|
|
53
|
+
expires?: Date;
|
|
54
|
+
secure?: boolean;
|
|
55
|
+
httpOnly?: boolean;
|
|
56
|
+
sameSite?: 'Strict' | 'Lax' | 'None';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface RouteOptions {
|
|
60
|
+
auth?: boolean | {
|
|
61
|
+
role?: string;
|
|
62
|
+
scopes?: string[];
|
|
63
|
+
};
|
|
64
|
+
rateLimit?: boolean | {
|
|
65
|
+
/** max requests per window (default 100) */
|
|
66
|
+
max?: number;
|
|
67
|
+
/** window in seconds (default 60) */
|
|
68
|
+
window?: number;
|
|
69
|
+
};
|
|
70
|
+
cors?: boolean | {
|
|
71
|
+
origin?: string | string[];
|
|
72
|
+
methods?: string;
|
|
73
|
+
headers?: string;
|
|
74
|
+
};
|
|
75
|
+
cache?: boolean | {
|
|
76
|
+
/** seconds (default 3600) */
|
|
77
|
+
ttl?: number;
|
|
78
|
+
staleWhileRevalidate?: number;
|
|
79
|
+
};
|
|
80
|
+
compress?: boolean;
|
|
81
|
+
log?: boolean;
|
|
82
|
+
validate?: (ctx: Context) => boolean | Promise<boolean>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type Handler<E = any> = (ctx: Context<E>) => any | Promise<any>;
|
|
86
|
+
|
|
87
|
+
export class Context<E = any> {
|
|
36
88
|
constructor(
|
|
37
89
|
request: Request,
|
|
38
|
-
env:
|
|
90
|
+
env: E,
|
|
39
91
|
executionCtx: ExecutionContext,
|
|
40
92
|
params?: Record<string, string>,
|
|
41
|
-
parsedUrl?: URL | null
|
|
93
|
+
parsedUrl?: URL | null,
|
|
42
94
|
);
|
|
43
95
|
|
|
44
96
|
req: Request;
|
|
45
|
-
env:
|
|
97
|
+
env: E;
|
|
46
98
|
executionCtx: ExecutionContext;
|
|
47
99
|
params: Record<string, string>;
|
|
48
100
|
status: number;
|
|
49
101
|
headers: Headers;
|
|
50
102
|
url: URL;
|
|
51
103
|
|
|
52
|
-
|
|
53
|
-
private _cookies: Record<string, string> | null;
|
|
104
|
+
readonly query: URLSearchParams;
|
|
54
105
|
|
|
55
106
|
getCookie(name: string): string | null;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
path?: string; domain?: string; maxAge?: number; expires?: Date;
|
|
59
|
-
secure?: boolean; httpOnly?: boolean; sameSite?: 'Strict' | 'Lax' | 'None';
|
|
60
|
-
}): void;
|
|
61
|
-
deleteCookie(name: string, options?: {
|
|
62
|
-
path?: string; domain?: string; secure?: boolean; httpOnly?: boolean;
|
|
63
|
-
sameSite?: 'Strict' | 'Lax' | 'None';
|
|
64
|
-
}): void;
|
|
107
|
+
setCookie(name: string, value: string, options?: CookieOptions): void;
|
|
108
|
+
deleteCookie(name: string, options?: CookieOptions): void;
|
|
65
109
|
|
|
66
110
|
text(data: string, status?: number, headers?: Record<string, string>): Response;
|
|
67
111
|
json(data: any, status?: number, headers?: Record<string, string>): Response;
|
|
68
|
-
html(data: string, status?: number, headers?: Record<string, string>): Response;
|
|
69
112
|
|
|
70
|
-
|
|
113
|
+
/** Accepts a raw HTML string, JSX, or an array of JSX. */
|
|
114
|
+
html(
|
|
115
|
+
data: string | JSXNode | JSXNode[] | null | undefined,
|
|
116
|
+
status?: number,
|
|
117
|
+
headers?: Record<string, string>,
|
|
118
|
+
): Response;
|
|
71
119
|
}
|
|
72
120
|
|
|
73
|
-
|
|
121
|
+
/* ---------------- Edge ---------------- */
|
|
74
122
|
|
|
75
|
-
export interface
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
123
|
+
export interface SecurityConfig {
|
|
124
|
+
hashAuthTokens: boolean;
|
|
125
|
+
logSecurityEvents: boolean;
|
|
126
|
+
extraHeaders: Record<string, string> | null;
|
|
127
|
+
trustedProxies: string[] | null;
|
|
80
128
|
}
|
|
81
129
|
|
|
82
|
-
export
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
interface AuthOptions { role?: string; scopes?: string[]; }
|
|
86
|
-
interface RateLimitOptions { max?: number; window?: number; }
|
|
87
|
-
interface CorsOptions { origin?: string | string[]; methods?: string; headers?: string; }
|
|
88
|
-
interface CacheOptions { ttl?: number; staleWhileRevalidate?: number; }
|
|
89
|
-
|
|
90
|
-
interface RouteOptions {
|
|
91
|
-
auth?: boolean | AuthOptions;
|
|
92
|
-
rateLimit?: boolean | RateLimitOptions;
|
|
93
|
-
cors?: boolean | CorsOptions;
|
|
94
|
-
validate?: (ctx: Context) => boolean | Promise<boolean>;
|
|
95
|
-
cache?: boolean | CacheOptions;
|
|
96
|
-
compress?: boolean;
|
|
97
|
-
log?: boolean;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
interface SecurityOptions {
|
|
101
|
-
hashAuthTokens?: boolean;
|
|
102
|
-
logSecurityEvents?: boolean;
|
|
103
|
-
extraHeaders?: Record<string, string> | null;
|
|
104
|
-
trustedProxies?: string[] | null;
|
|
130
|
+
export interface Defaults {
|
|
131
|
+
cors: { origin: string; methods: string };
|
|
105
132
|
}
|
|
106
133
|
|
|
107
|
-
|
|
108
|
-
type ScheduledHandler = (controller: ScheduledController, env: any, ctx: ExecutionContext) => void | Promise<void>;
|
|
109
|
-
|
|
110
|
-
export class Edge {
|
|
134
|
+
export class Edge<E = any> {
|
|
111
135
|
constructor();
|
|
136
|
+
|
|
112
137
|
authKvBinding: string;
|
|
113
138
|
rateLimitKvBinding: string;
|
|
114
|
-
defaults:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
get(path: string, handler:
|
|
119
|
-
|
|
120
|
-
post(path: string, handler
|
|
121
|
-
post(path: string,
|
|
122
|
-
|
|
123
|
-
put(path: string, options
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
139
|
+
defaults: Defaults;
|
|
140
|
+
security: SecurityConfig;
|
|
141
|
+
|
|
142
|
+
get(path: string, options?: RouteOptions | boolean, handler?: Handler<E>): void;
|
|
143
|
+
get(path: string, handler: Handler<E>): void;
|
|
144
|
+
|
|
145
|
+
post(path: string, options?: RouteOptions | boolean, handler?: Handler<E>): void;
|
|
146
|
+
post(path: string, handler: Handler<E>): void;
|
|
147
|
+
|
|
148
|
+
put(path: string, options?: RouteOptions | boolean, handler?: Handler<E>): void;
|
|
149
|
+
put(path: string, handler: Handler<E>): void;
|
|
150
|
+
|
|
151
|
+
delete(path: string, options?: RouteOptions | boolean, handler?: Handler<E>): void;
|
|
152
|
+
delete(path: string, handler: Handler<E>): void;
|
|
153
|
+
|
|
154
|
+
patch(path: string, options?: RouteOptions | boolean, handler?: Handler<E>): void;
|
|
155
|
+
patch(path: string, handler: Handler<E>): void;
|
|
156
|
+
|
|
157
|
+
options(path: string, options?: RouteOptions | boolean, handler?: Handler<E>): void;
|
|
158
|
+
options(path: string, handler: Handler<E>): void;
|
|
159
|
+
|
|
160
|
+
head(path: string, options?: RouteOptions | boolean, handler?: Handler<E>): void;
|
|
161
|
+
head(path: string, handler: Handler<E>): void;
|
|
162
|
+
|
|
163
|
+
scheduled(
|
|
164
|
+
handler: (event: ScheduledEvent, env: E, ctx: ExecutionContext) => any | Promise<any>,
|
|
165
|
+
): void;
|
|
166
|
+
|
|
167
|
+
fetch(request: Request, env: E, executionCtx: ExecutionContext): Promise<Response>;
|
|
134
168
|
}
|
|
135
169
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
170
|
+
/* ---------------- JSX typing for this runtime ---------------- */
|
|
171
|
+
|
|
172
|
+
export namespace JSX {
|
|
173
|
+
type Element = JSXNode;
|
|
174
|
+
interface IntrinsicElements {
|
|
175
|
+
[elemName: string]: any;
|
|
176
|
+
}
|
|
177
|
+
interface ElementChildrenAttribute {
|
|
178
|
+
children: {};
|
|
140
179
|
}
|
|
141
180
|
}
|
package/dist/edge-server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* @lengkapp/edge v0.0.
|
|
2
|
+
* @lengkapp/edge v0.0.19
|
|
3
3
|
* MIT License
|
|
4
4
|
*
|
|
5
5
|
* LengkApp Edge License
|
|
@@ -30,4 +30,4 @@
|
|
|
30
30
|
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
31
31
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
32
32
|
*/
|
|
33
|
-
var t=Object.defineProperty,e=Object.getOwnPropertyDescriptor,n=Object.getOwnPropertyNames,o=Object.prototype.hasOwnProperty,r=(e,n)=>{for(var o in n)t(e,o,{get:n[o],enumerable:!0})},i=(r,i,s,a)=>{if(i&&"object"==typeof i||"function"==typeof i)for(let c of n(i))o.call(r,c)||c===s||t(r,c,{get:()=>i[c],enumerable:!(a=e(i,c))||a.enumerable});return r},s=e=>i(t({},"__esModule",{value:!0}),e),a={};function c(t){const e=[],n=t.length;let o=1;for(let r=1;r<=n;r++)r!==n&&47!==t.charCodeAt(r)||(r>o&&e.push(t.slice(o,r)),o=r+1);return e}function l(t){const e=t.length;return e>1&&47===t.charCodeAt(e-1)?t.slice(0,-1):t}r(a,{Edge:()=>Edge,Fragment:()=>Fragment,jsx:()=>jsx,renderToString:()=>renderToString}),module.exports=s(a);var u=new Set(["__proto__","constructor","prototype"]);function f(t){return"string"==typeof t&&!u.has(t)}function h(t){const e=JSON.parse(t);if(e&&"object"==typeof e)for(const t of Object.keys(e))if(!f(t))throw new SyntaxError("Forbidden key in JSON payload");return e}var p=class{constructor(t,e,n,o={},r=null){this.req=t,this.env=e,this.executionCtx=n,this.params=o,this.status=200,this.headers=new Headers,this.headers.set("X-Content-Type-Options","nosniff"),this.headers.set("X-Frame-Options","DENY"),this.headers.set("Referrer-Policy","strict-origin-when-cross-origin"),this.headers.set("Permissions-Policy","geolocation=(), microphone=(), camera=()"),this.t=t.headers.get("Cookie")||"",this.o=null,this.url=r||new URL(t.url)}i(){if(null===this.o){const t=Object.create(null);if(this.t)for(const e of this.t.split(";")){const n=e.trim();if(!n)continue;const o=n.indexOf("=");if(o>0)try{const e=decodeURIComponent(n.slice(0,o)),r=decodeURIComponent(n.slice(o+1));f(e)&&(t[e]=r)}catch{}}this.o=t}return this.o}getCookie(t){return this.i()[t]??null}get query(){return this.url.searchParams}setCookie(t,e,n={}){if("string"!=typeof t||!f(t)||/[\r\n;=]/.test(t))return;let o=`${encodeURIComponent(t)}=${encodeURIComponent(e)}`;n.path&&(o+=`; Path=${n.path}`),n.domain&&(o+=`; Domain=${n.domain}`),void 0!==n.maxAge&&(o+=`; Max-Age=${n.maxAge}`),n.expires&&(o+=`; Expires=${n.expires.toUTCString()}`),n.secure&&(o+="; Secure"),n.httpOnly&&(o+="; HttpOnly"),n.sameSite&&(o+=`; SameSite=${n.sameSite}`),this.headers.append("Set-Cookie",o)}deleteCookie(t,e={}){this.setCookie(t,"",{...e,maxAge:0,expires:new Date(0)})}text(t,e=this.status,n={}){const o=this.l(n);return o.has("Content-Type")||o.set("Content-Type","text/plain; charset=utf-8"),new Response(t,{status:e,headers:o})}json(t,e=this.status,n={}){const o=this.l(n);return o.has("Content-Type")||o.set("Content-Type","application/json; charset=utf-8"),new Response(JSON.stringify(t),{status:e,headers:o})}html(t,e=this.status,n={}){const o=this.l(n);return o.has("Content-Type")||o.set("Content-Type","text/html; charset=utf-8"),new Response(t,{status:e,headers:o})}l(t){if(0===Object.keys(t).length)return this.headers;const e=new Headers(this.headers);for(const n in t)Object.prototype.hasOwnProperty.call(t,n)&&e.set(n,t[n]);return e}},Fragment=Symbol("Fragment");function jsx(t,e,...n){return{type:t,props:e||{},children:n.flat(1/0),__isJSX:!0}}var d=new Set(["area","base","br","col","embed","hr","img","input","link","meta","param","source","track","wbr"]),y=new Set(["allowfullscreen","async","autofocus","autoplay","checked","controls","default","defer","disabled","formnovalidate","hidden","inert","ismap","itemscope","loop","multiple","muted","nomodule","novalidate","open","playsinline","readonly","required","reversed","selected"]),m=new Set(["animation-iteration-count","border-image-outset","border-image-slice","border-image-width","box-flex","box-flex-group","box-ordinal-group","column-count","columns","flex","flex-grow","flex-positive","flex-shrink","flex-negative","flex-order","grid-row","grid-row-end","grid-row-span","grid-row-start","grid-column","grid-column-end","grid-column-span","grid-column-start","font-weight","line-clamp","line-height","opacity","order","orphans","tab-size","widows","z-index","zoom","fill-opacity","flood-opacity","stop-opacity","stroke-dasharray","stroke-dashoffset","stroke-miterlimit","stroke-opacity","stroke-width"]),g={"&":"&","<":"<",">":">",'"':""","'":"'"},w=/[&<>"']/g;function b(t){return w.lastIndex=0,w.test(t)?t.replace(w,t=>g[t]):t}var A=new Map,T=2e3;function _(t){const e=A.get(t);if(void 0!==e)return e;const n=t.replace(/([A-Z])/g,"-$1").toLowerCase().replace(/^-/,"");return A.size>=T&&A.clear(),A.set(t,n),n}function C(t){if(!t||"object"!=typeof t)return"";const e=Object.entries(t);return 0===e.length?"":e.map(([t,e])=>{let n=_(t);return"number"!=typeof e||m.has(n)||(e=`${e}px`),`${n}:${e}`}).join(";")}function renderToString(t){if(null==t||"boolean"==typeof t)return"";if("string"==typeof t||"number"==typeof t)return b(String(t));if(Array.isArray(t)){const e=[];for(let n=0;n<t.length;n++)e.push(renderToString(t[n]));return e.join("")}if(!t.__isJSX)return b(String(t));const{type:e,props:n,children:o}=t;if(e===Fragment){const t=[];for(let e=0;e<o.length;e++)t.push(renderToString(o[e]));return t.join("")}if("symbol"==typeof e)return"";if("function"==typeof e)return renderToString(e({...n,children:o}));if("string"!=typeof e||!/^[A-Za-z][A-Za-z0-9-]*$/.test(e))return"";const r=d.has(e),i=[];for(const t in n){if(!Object.prototype.hasOwnProperty.call(n,t))continue;if("children"===t||"key"===t||"ref"===t)continue;const e=n[t];if(null==e||!1===e)continue;if("dangerouslySetInnerHTML"===t)continue;let o=t;if("className"===t||"class"===t?o="class":"htmlFor"!==t&&"for"!==t||(o="for"),f(o)&&!/^on/i.test(o)&&/^[A-Za-z_:][A-Za-z0-9_:.-]*$/.test(o)){if("href"===o||"src"===o||"xlink:href"===o||"action"===o||"formaction"===o){const t=String(e).replace(/[\u0000-\u0020]/g,"").toLowerCase();if(t.startsWith("javascript:")||t.startsWith("vbscript:")||t.startsWith("data:text/html"))continue}if(y.has(o))!0===e&&i.push(` ${o}`);else if(!0!==e){if("style"===o&&"object"==typeof e){const t=C(e);t&&i.push(` style="${b(t)}"`);continue}i.push(` ${o}="${b(String(e))}"`)}else i.push(` ${o}="true"`)}}const s=i.join("");let a="";if(n&&n.dangerouslySetInnerHTML&&null!=n.dangerouslySetInnerHTML.__html)a=n.dangerouslySetInnerHTML.__html;else{const t=[];for(let e=0;e<o.length;e++)t.push(renderToString(o[e]));a=t.join("")}return r?`<${e}${s}/>`:`<${e}${s}>${a}</${e}>`}var k=class{constructor(){this.children=null,this.paramChild=null,this.handler=null}},O=class{constructor(){this.root=new k}add(t,e,n){let o=this.root;const r=[];for(let e=0;e<t.length;e++){const n=t[e];if(58===n.charCodeAt(0)){const t=n.slice(1);r.push(f(t)?t:`_p${e}`),o.paramChild||(o.paramChild=new k),o=o.paramChild}else{o.children||(o.children=new Map);let t=o.children.get(n);t||(t=new k,o.children.set(n,t)),o=t}}o.handler={handler:e,options:n,paramNames:r}}match(t){const e=[],n=this.u(this.root,t,0,e);if(!n)return null;const{handler:o,options:r,paramNames:i}=n,s=Object.create(null);for(let t=0;t<i.length;t++)s[i[t]]=e[t];return{handler:o,options:r,params:s}}u(t,e,n,o){if(n===e.length)return t.handler;const r=e[n];if(t.children){const i=t.children.get(r);if(i){const t=this.u(i,e,n+1,o);if(t)return t}}if(t.paramChild){o.push(r);const i=this.u(t.paramChild,e,n+1,o);if(i)return i;o.pop()}return null}},S=["GET","POST","PUT","DELETE","PATCH","OPTIONS","HEAD"],$=new Map;function E(t,e,n){const o=`${t}|${e||""}|${n||""}`,r=Date.now(),i=$.get(o);if(i&&r-i<6e4)return!1;if($.set(o,r),$.size>5e3)for(const[t,e]of $)r-e>6e4&&$.delete(t);return!0}var Edge=class{constructor(){this.staticRoutes=new Map;for(const t of S)this.staticRoutes.set(t,new Map);this.dynamicTries={};for(const t of S)this.dynamicTries[t]=new O;this.authKvBinding="AUTH_KV",this.rateLimitKvBinding="RATE_LIMIT_KV",this.defaults={cors:{origin:"*",methods:"GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD"}},this.scheduledHandler=null,this.security={hashAuthTokens:!1,logSecurityEvents:!0,extraHeaders:null,trustedProxies:null}}h(t,e,n,o){if("boolean"==typeof n&&(n={auth:n}),"function"==typeof n&&(o=n,n={}),"function"!=typeof o)throw new Error(`Handler for ${t} ${e} must be a function`);const r={...this.defaults,...n||{}};if(-1===e.indexOf(":")){const n=l(e);this.staticRoutes.get(t).set(n,{handler:o,options:r})}else this.dynamicTries[t].add(c(e),o,r)}get(t,e,n){this.h("GET",t,e,n)}post(t,e,n){this.h("POST",t,e,n)}put(t,e,n){this.h("PUT",t,e,n)}delete(t,e,n){this.h("DELETE",t,e,n)}patch(t,e,n){this.h("PATCH",t,e,n)}options(t,e,n){this.h("OPTIONS",t,e,n)}head(t,e,n){this.h("HEAD",t,e,n)}scheduled(t){this.scheduledHandler=t}p(t,e,n){if(this.security.logSecurityEvents)try{const o=t.req.headers.get("CF-Connecting-IP")||"",r=t.url?t.url.pathname:"";if(!E(e,o,r))return;console.log(JSON.stringify({ts:(new Date).toISOString(),kind:"security",event:e,detail:"string"==typeof n?n:void 0,ip:o||void 0,method:t.req.method,path:r,ua:t.req.headers.get("User-Agent")||void 0}))}catch{}}async m(t,e){if(!e)return{ok:!0};const n=t.getCookie("auth_token")||t.req.headers.get("Authorization")?.replace(/^Bearer\s+/i,"");if(!n)return{ok:!1,reason:"missing_token"};const o=t.env[this.authKvBinding];if(!o)return{ok:!1,reason:"kv_unavailable"};let r,i=n;if(this.security.hashAuthTokens&&globalThis.crypto&&crypto.subtle){const t=await crypto.subtle.digest("SHA-256",(new TextEncoder).encode(n)),e=new Uint8Array(t);let o="";for(let t=0;t<e.length;t++)o+=e[t].toString(16).padStart(2,"0");i="tok:"+o}try{r=await o.get(i)}catch{return{ok:!1,reason:"kv_error"}}if(!r)return{ok:!1,reason:"invalid_token"};if("object"==typeof e&&null!==e){let t;try{t=h(r)}catch{return{ok:!1,reason:"malformed_payload"}}if(t&&"object"==typeof t){if(null!=t.exp){const e=Date.now()/1e3;if("number"==typeof t.exp&&t.exp<e)return{ok:!1,reason:"expired_token"}}if(e.role&&t.role!==e.role)return{ok:!1,reason:"insufficient_role"};if(e.scopes){const n=Array.isArray(t.scopes)?t.scopes:[];for(let t=0;t<e.scopes.length;t++)if(-1===n.indexOf(e.scopes[t]))return{ok:!1,reason:"insufficient_scopes"}}}}return{ok:!0,payload:r}}async A(t,e){if(!e)return!0;const n=!0===e?{}:e,{max:o=100,window:r=60}=n,i=`rl:${t.req.headers.get("CF-Connecting-IP")||"unknown"}`,s=t.env[this.rateLimitKvBinding];if(!s)return!0;let a;try{a=await s.get(i,"json")}catch{return!0}if(("number"!=typeof a||!isFinite(a)||a<0)&&(a=0),a>=o)return!1;a++;try{await s.put(i,JSON.stringify(a),{expirationTtl:r})}catch{}return!0}T(t,e){if(!e)return;const n="object"==typeof e?e:this.defaults.cors,o=t.req.headers.get("Origin"),r=n.origin;"*"===r?t.headers.set("Access-Control-Allow-Origin","*"):Array.isArray(r)?o&&-1!==r.indexOf(o)&&(t.headers.set("Access-Control-Allow-Origin",o),t.headers.set("Vary","Origin"),t.headers.set("Access-Control-Allow-Credentials","true")):"string"==typeof r&&o===r&&(t.headers.set("Access-Control-Allow-Origin",o),t.headers.set("Vary","Origin"),t.headers.set("Access-Control-Allow-Credentials","true")),t.headers.set("Access-Control-Allow-Methods",n.methods||"GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD"),t.headers.set("Access-Control-Allow-Headers",n.headers||"Content-Type, Authorization, X-CSRF-Token"),t.headers.set("Access-Control-Max-Age","86400")}_(t,e){e&&console.log(`${t.req.method} ${t.req.url} - ${t.status}`)}C(t,e){const n=t.headers.get("Accept-Encoding")||"";let o=null;if(n.includes("gzip")?o="gzip":n.includes("deflate")&&(o="deflate"),!o||!e.body)return e;const r=e.body.pipeThrough(new CompressionStream(o)),i=new Headers(e.headers);return i.set("Content-Encoding",o),i.set("Vary","Accept-Encoding"),i.delete("Content-Length"),new Response(r,{status:e.status,statusText:e.statusText,headers:i})}async k(t,e){return!e||"function"!=typeof e||!!await e(t)}async O(t){return await caches.default.match(t)||null}async S(t,e,n){if(!n)return;const o=!0===n?{}:n,{ttl:r=3600,staleWhileRevalidate:i=0}=o,s=e.clone();s.headers.set("Cache-Control",`max-age=${r}${i>0?`, stale-while-revalidate=${i}`:""}`),s.headers.delete("Set-Cookie"),await caches.default.put(t,s)}async fetch(t,e,n){const o=new URL(t.url),r=l(o.pathname),i=t.method,s=this.staticRoutes.get(i);if(s){const i=s.get(r);if(i)return this.$(i.handler,i.options,t,e,n,Object.create(null),o)}const a=this.dynamicTries[i];if(a){const i=a.match(c(r));if(i)return this.$(i.handler,i.options,t,e,n,i.params,o)}return new Response("Not Found",{status:404})}async $(t,e,n,o,r,i,s){const a=new p(n,o,r,i,s);if(this.security.extraHeaders)for(const t in this.security.extraHeaders)Object.prototype.hasOwnProperty.call(this.security.extraHeaders,t)&&a.headers.set(t,this.security.extraHeaders[t]);let c,l=!1;try{l=await this.k(a,e.validate)}catch(t){this.p(a,"validation_error",t&&t.message),l=!1}if(!l)return a.text("Validation failed",400);try{c=await this.m(a,e.auth)}catch(t){this.p(a,"auth_error",t&&t.message),c={ok:!1,reason:"auth_error"}}if(!("boolean"==typeof c?c:c&&c.ok))return this.p(a,"auth_failure",c&&c.reason||"denied"),a.text("Unauthorized",401);let u,f=!1;try{f=await this.A(a,e.rateLimit)}catch(t){this.p(a,"rate_limit_error",t&&t.message),f=!1}if(!f)return this.p(a,"rate_limit_exceeded",null),a.text("Too Many Requests",429,{"Retry-After":"60"});if(e.cache&&"GET"===n.method){const t=await this.O(n);if(t)return this.T(a,e.cors),this._(a,e.log),t}this.T(a,e.cors);try{const e=await t(a);u=e&&e.__isJSX?a.html(renderToString(e)):e instanceof Response?e:a.text("OK"),a.status=u.status}catch(t){this.p(a,"handler_error",t&&t.message),u=a.text("Internal Server Error",500),a.status=500}return e.cache&&"GET"===n.method&&200===u.status&&a.executionCtx.waitUntil(this.S(n,u.clone(),e.cache)),e.compress&&(u=this.C(n,u)),this._(a,e.log),u}};
|
|
33
|
+
var t=Object.defineProperty,e=Object.getOwnPropertyDescriptor,n=Object.getOwnPropertyNames,r=Object.prototype.hasOwnProperty,o=(e,n)=>{for(var r in n)t(e,r,{get:n[r],enumerable:!0})},i=(o,i,s,a)=>{if(i&&"object"==typeof i||"function"==typeof i)for(let c of n(i))r.call(o,c)||c===s||t(o,c,{get:()=>i[c],enumerable:!(a=e(i,c))||a.enumerable});return o},s=e=>i(t({},"__esModule",{value:!0}),e),a={};function c(t){const e=[],n=t.length;let r=1;for(let o=1;o<=n;o++)o!==n&&47!==t.charCodeAt(o)||(o>r&&e.push(t.slice(r,o)),r=o+1);return e}function l(t){const e=t.length;return e>1&&47===t.charCodeAt(e-1)?t.slice(0,-1):t}o(a,{Context:()=>T,Edge:()=>Edge,Fragment:()=>Fragment,jsx:()=>jsx,renderToString:()=>renderToString}),module.exports=s(a);var u=new Set(["__proto__","constructor","prototype"]);function f(t){return"string"==typeof t&&!u.has(t)}function h(t){const e=JSON.parse(t);if(e&&"object"==typeof e)for(const t of Object.keys(e))if(!f(t))throw new SyntaxError("Forbidden key in JSON payload");return e}function p(t){const e=t.length;let n=0;for(;n<e;){const e=t.charCodeAt(n);if(38===e||60===e||62===e||34===e||39===e)break;n++}if(n===e)return t;let r=t.slice(0,n),o=n;for(;n<e;n++){const e=t.charCodeAt(n);let i;if(38===e)i="&";else if(60===e)i="<";else if(62===e)i=">";else if(34===e)i=""";else{if(39!==e)continue;i="'"}n>o&&(r+=t.slice(o,n)),r+=i,o=n+1}return o<e&&(r+=t.slice(o)),r}var Fragment=Symbol("Fragment");function jsx(t,e,...n){return{type:t,props:e||{},children:n,__isJSX:!0}}var d=new Set(["area","base","br","col","embed","hr","img","input","link","meta","param","source","track","wbr"]),y=new Set(["allowfullscreen","async","autofocus","autoplay","checked","controls","default","defer","disabled","formnovalidate","hidden","inert","ismap","itemscope","loop","multiple","muted","nomodule","novalidate","open","playsinline","readonly","required","reversed","selected"]),m=new Set(["animation-iteration-count","border-image-outset","border-image-slice","border-image-width","box-flex","box-flex-group","box-ordinal-group","column-count","columns","flex","flex-grow","flex-positive","flex-shrink","flex-negative","flex-order","grid-row","grid-row-end","grid-row-span","grid-row-start","grid-column","grid-column-end","grid-column-span","grid-column-start","font-weight","line-clamp","line-height","opacity","order","orphans","tab-size","widows","z-index","zoom","fill-opacity","flood-opacity","stop-opacity","stroke-dasharray","stroke-dashoffset","stroke-miterlimit","stroke-opacity","stroke-width"]),g=new Map,w=2e3;function b(t){const e=g.get(t);if(void 0!==e)return e;const n=t.replace(/([A-Z])/g,"-$1").toLowerCase().replace(/^-/,"");return g.size>=w&&g.clear(),g.set(t,n),n}function A(t){if(!t||"object"!=typeof t)return"";let e="";for(const n in t){if(!Object.prototype.hasOwnProperty.call(t,n))continue;const r=b(n);let o=t[n];"number"!=typeof o||m.has(r)||(o+="px"),e&&(e+=";"),e+=r+":"+o}return e}var C=new Set(["href","src","xlink:href","action","formaction"]);function renderToString(t){if(null==t||"boolean"==typeof t)return"";const e=typeof t;if("string"===e)return p(t);if("number"===e)return p(String(t));if(Array.isArray(t)){let e="";for(let n=0;n<t.length;n++)e+=renderToString(t[n]);return e}if(!t.__isJSX)return p(String(t));const{type:n,props:r,children:o}=t;if(n===Fragment){let t="";for(let e=0;e<o.length;e++)t+=renderToString(o[e]);return t}if("symbol"==typeof n)return"";if("function"==typeof n)return renderToString(n({...r,children:o}));if("string"!=typeof n||!/^[A-Za-z][A-Za-z0-9-]*$/.test(n))return"";const i=d.has(n);let s="";for(const t in r){if(!Object.prototype.hasOwnProperty.call(r,t))continue;if("children"===t||"key"===t||"ref"===t)continue;const e=r[t];if(null==e||!1===e)continue;if("dangerouslySetInnerHTML"===t)continue;let n=t;if("className"===t||"class"===t?n="class":"htmlFor"!==t&&"for"!==t||(n="for"),f(n)&&!/^on/i.test(n)&&/^[A-Za-z_:][A-Za-z0-9_:.-]*$/.test(n)){if(C.has(n)){const t=String(e).replace(/[\u0000-\u0020]/g,"").toLowerCase();if(t.startsWith("javascript:")||t.startsWith("vbscript:")||t.startsWith("data:text/html"))continue}if(y.has(n))!0===e&&(s+=" "+n);else if(!0!==e){if("style"===n&&"object"==typeof e){const t=A(e);t&&(s+=' style="'+p(t)+'"');continue}s+=" "+n+'="'+p(String(e))+'"'}else s+=" "+n+'="true"'}}let a="";const c=r.dangerouslySetInnerHTML;if(c&&null!=c.__html)a=c.__html;else for(let t=0;t<o.length;t++)a+=renderToString(o[t]);return i?"<"+n+s+"/>":"<"+n+s+">"+a+"</"+n+">"}var T=class{constructor(t,e,n,r={},o=null){this.req=t,this.env=e,this.executionCtx=n,this.params=r,this.status=200,this.headers=new Headers,this.headers.set("X-Content-Type-Options","nosniff"),this.headers.set("X-Frame-Options","DENY"),this.headers.set("Referrer-Policy","strict-origin-when-cross-origin"),this.headers.set("Permissions-Policy","geolocation=(), microphone=(), camera=()"),this.t=t.headers.get("Cookie")||"",this.o=null,this.url=o||new URL(t.url)}i(){if(null!==this.o)return this.o;const t=Object.create(null),e=this.t;if(e){let n=0;const r=e.length;for(;n<r;){let o=e.indexOf(";",n);-1===o&&(o=r);let i=n,s=o;for(;i<s&&32===e.charCodeAt(i);)i++;for(;s>i&&32===e.charCodeAt(s-1);)s--;if(s>i){const n=e.indexOf("=",i);if(n>i&&n<s)try{const r=decodeURIComponent(e.slice(i,n));f(r)&&(t[r]=decodeURIComponent(e.slice(n+1,s)))}catch{}}n=o+1}}return this.o=t,t}getCookie(t){const e=this.i()[t];return void 0===e?null:e}get query(){return this.url.searchParams}setCookie(t,e,n={}){if("string"!=typeof t||!f(t)||/[\r\n;=]/.test(t))return;let r=encodeURIComponent(t)+"="+encodeURIComponent(e);n.path&&(r+="; Path="+n.path),n.domain&&(r+="; Domain="+n.domain),void 0!==n.maxAge&&(r+="; Max-Age="+n.maxAge),n.expires&&(r+="; Expires="+n.expires.toUTCString()),n.secure&&(r+="; Secure"),n.httpOnly&&(r+="; HttpOnly"),n.sameSite&&(r+="; SameSite="+n.sameSite),this.headers.append("Set-Cookie",r)}deleteCookie(t,e={}){this.setCookie(t,"",{...e,maxAge:0,expires:new Date(0)})}l(t){if(!t)return this.headers;let e=!1;for(const n in t){e=!0;break}if(!e)return this.headers;const n=new Headers(this.headers);for(const e in t)Object.prototype.hasOwnProperty.call(t,e)&&n.set(e,t[e]);return n}text(t,e=this.status,n={}){const r=this.l(n);return r.has("Content-Type")||r.set("Content-Type","text/plain; charset=utf-8"),new Response(t,{status:e,headers:r})}json(t,e=this.status,n={}){const r=this.l(n);return r.has("Content-Type")||r.set("Content-Type","application/json; charset=utf-8"),new Response(JSON.stringify(t),{status:e,headers:r})}html(t,e=this.status,n={}){const r=this.l(n);r.has("Content-Type")||r.set("Content-Type","text/html; charset=utf-8");const o="string"==typeof t?t:t&&t.__isJSX||Array.isArray(t)?renderToString(t):null==t?"":String(t);return new Response(o,{status:e,headers:r})}},_=class{constructor(){this.children=null,this.paramChild=null,this.handler=null}},k=class{constructor(){this.root=new _}add(t,e,n){let r=this.root;const o=[];for(let e=0;e<t.length;e++){const n=t[e];if(58===n.charCodeAt(0)){const t=n.slice(1);o.push(f(t)?t:"_p"+e),r.paramChild||(r.paramChild=new _),r=r.paramChild}else{r.children||(r.children=new Map);let t=r.children.get(n);t||(t=new _,r.children.set(n,t)),r=t}}r.handler={handler:e,options:n,paramNames:o}}match(t){const e=[],n=this.u(this.root,t,0,e);if(!n)return null;const{handler:r,options:o,paramNames:i}=n,s=Object.create(null);for(let t=0;t<i.length;t++)s[i[t]]=e[t];return{handler:r,options:o,params:s}}u(t,e,n,r){if(n===e.length)return t.handler;const o=e[n],i=t.children;if(i){const t=i.get(o);if(t){const o=this.u(t,e,n+1,r);if(o)return o}}const s=t.paramChild;if(s){r.push(o);const t=this.u(s,e,n+1,r);if(t)return t;r.pop()}return null}},O=["GET","POST","PUT","DELETE","PATCH","OPTIONS","HEAD"],S=new Map;function E(t,e,n){const r=t+"|"+(e||"")+"|"+(n||""),o=Date.now(),i=S.get(r);if(i&&o-i<6e4)return!1;if(S.set(r,o),S.size>5e3)for(const[t,e]of S)o-e>6e4&&S.delete(t);return!0}var Edge=class{constructor(){this.staticRoutes=new Map;for(let t=0;t<O.length;t++)this.staticRoutes.set(O[t],new Map);this.dynamicTries={};for(let t=0;t<O.length;t++)this.dynamicTries[O[t]]=new k;this.authKvBinding="AUTH_KV",this.rateLimitKvBinding="RATE_LIMIT_KV",this.defaults={cors:{origin:"*",methods:"GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD"}},this.scheduledHandler=null,this.security={hashAuthTokens:!1,logSecurityEvents:!0,extraHeaders:null,trustedProxies:null}}h(t,e,n,r){if("boolean"==typeof n?n={auth:n}:"function"==typeof n&&(r=n,n={}),"function"!=typeof r)throw new Error("Handler for "+t+" "+e+" must be a function");const o={...this.defaults,...n||{}};-1===e.indexOf(":")?this.staticRoutes.get(t).set(l(e),{handler:r,options:o}):this.dynamicTries[t].add(c(e),r,o)}get(t,e,n){this.h("GET",t,e,n)}post(t,e,n){this.h("POST",t,e,n)}put(t,e,n){this.h("PUT",t,e,n)}delete(t,e,n){this.h("DELETE",t,e,n)}patch(t,e,n){this.h("PATCH",t,e,n)}options(t,e,n){this.h("OPTIONS",t,e,n)}head(t,e,n){this.h("HEAD",t,e,n)}scheduled(t){this.scheduledHandler=t}p(t,e,n){if(this.security.logSecurityEvents)try{const r=t.req.headers.get("CF-Connecting-IP")||"",o=t.url?t.url.pathname:"";if(!E(e,r,o))return;console.log(JSON.stringify({ts:(new Date).toISOString(),kind:"security",event:e,detail:"string"==typeof n?n:void 0,ip:r||void 0,method:t.req.method,path:o,ua:t.req.headers.get("User-Agent")||void 0}))}catch{}}async m(t,e){if(!e)return{ok:!0};const n=t.getCookie("auth_token")||t.req.headers.get("Authorization")?.replace(/^Bearer\s+/i,"");if(!n)return{ok:!1,reason:"missing_token"};const r=t.env[this.authKvBinding];if(!r)return{ok:!1,reason:"kv_unavailable"};let o,i=n;if(this.security.hashAuthTokens&&globalThis.crypto&&crypto.subtle){const t=await crypto.subtle.digest("SHA-256",(new TextEncoder).encode(n)),e=new Uint8Array(t);let r="";for(let t=0;t<e.length;t++)r+=e[t].toString(16).padStart(2,"0");i="tok:"+r}try{o=await r.get(i)}catch{return{ok:!1,reason:"kv_error"}}if(!o)return{ok:!1,reason:"invalid_token"};if("object"==typeof e&&null!==e){let t;try{t=h(o)}catch{return{ok:!1,reason:"malformed_payload"}}if(t&&"object"==typeof t){if(null!=t.exp&&"number"==typeof t.exp&&t.exp<Date.now()/1e3)return{ok:!1,reason:"expired_token"};if(e.role&&t.role!==e.role)return{ok:!1,reason:"insufficient_role"};if(e.scopes){const n=Array.isArray(t.scopes)?t.scopes:[];for(let t=0;t<e.scopes.length;t++)if(-1===n.indexOf(e.scopes[t]))return{ok:!1,reason:"insufficient_scopes"}}}}return{ok:!0,payload:o}}async A(t,e){if(!e)return!0;const n=!0===e?{}:e,r=n.max??100,o=n.window??60,i="rl:"+(t.req.headers.get("CF-Connecting-IP")||"unknown"),s=t.env[this.rateLimitKvBinding];if(!s)return!0;let a;try{a=await s.get(i,"json")}catch{return!0}if(("number"!=typeof a||!isFinite(a)||a<0)&&(a=0),a>=r)return!1;a++;try{await s.put(i,JSON.stringify(a),{expirationTtl:o})}catch{}return!0}C(t,e){if(!e)return;const n="object"==typeof e?e:this.defaults.cors,r=t.req.headers.get("Origin"),o=n.origin;"*"===o?t.headers.set("Access-Control-Allow-Origin","*"):Array.isArray(o)?r&&-1!==o.indexOf(r)&&(t.headers.set("Access-Control-Allow-Origin",r),t.headers.set("Vary","Origin"),t.headers.set("Access-Control-Allow-Credentials","true")):"string"==typeof o&&r===o&&(t.headers.set("Access-Control-Allow-Origin",r),t.headers.set("Vary","Origin"),t.headers.set("Access-Control-Allow-Credentials","true")),t.headers.set("Access-Control-Allow-Methods",n.methods||"GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD"),t.headers.set("Access-Control-Allow-Headers",n.headers||"Content-Type, Authorization, X-CSRF-Token"),t.headers.set("Access-Control-Max-Age","86400")}T(t,e){e&&console.log(t.req.method+" "+t.req.url+" - "+t.status)}_(t,e){const n=t.headers.get("Accept-Encoding")||"";let r=null;if(-1!==n.indexOf("gzip")?r="gzip":-1!==n.indexOf("deflate")&&(r="deflate"),!r||!e.body)return e;const o=e.body.pipeThrough(new CompressionStream(r)),i=new Headers(e.headers);return i.set("Content-Encoding",r),i.set("Vary","Accept-Encoding"),i.delete("Content-Length"),new Response(o,{status:e.status,statusText:e.statusText,headers:i})}async k(t,e){return!e||"function"!=typeof e||!!await e(t)}async O(t){return await caches.default.match(t)||null}async S(t,e,n){if(!n)return;const r=!0===n?{}:n,o=r.ttl??3600,i=r.staleWhileRevalidate??0,s=e.clone();s.headers.set("Cache-Control","max-age="+o+(i>0?", stale-while-revalidate="+i:"")),s.headers.delete("Set-Cookie"),await caches.default.put(t,s)}async fetch(t,e,n){const r=new URL(t.url),o=l(r.pathname),i=t.method,s=this.staticRoutes.get(i);if(s){const i=s.get(o);if(i)return this.v(i.handler,i.options,t,e,n,Object.create(null),r)}const a=this.dynamicTries[i];if(a){const i=a.match(c(o));if(i)return this.v(i.handler,i.options,t,e,n,i.params,r)}return new Response("Not Found",{status:404})}async v(t,e,n,r,o,i,s){const a=new T(n,r,o,i,s),c=this.security.extraHeaders;if(c)for(const t in c)Object.prototype.hasOwnProperty.call(c,t)&&a.headers.set(t,c[t]);let l,u=!1;try{u=await this.k(a,e.validate)}catch(t){this.p(a,"validation_error",t&&t.message),u=!1}if(!u)return a.text("Validation failed",400);try{l=await this.m(a,e.auth)}catch(t){this.p(a,"auth_error",t&&t.message),l={ok:!1,reason:"auth_error"}}if(!("boolean"==typeof l?l:l&&l.ok))return this.p(a,"auth_failure",l&&l.reason||"denied"),a.text("Unauthorized",401);let f,h=!1;try{h=await this.A(a,e.rateLimit)}catch(t){this.p(a,"rate_limit_error",t&&t.message),h=!1}if(!h)return this.p(a,"rate_limit_exceeded",null),a.text("Too Many Requests",429,{"Retry-After":"60"});if(e.cache&&"GET"===n.method){const t=await this.O(n);if(t)return this.C(a,e.cors),this.T(a,e.log),t}this.C(a,e.cors);try{const e=await t(a);f=e instanceof Response?e:e&&e.__isJSX?a.html(e):a.text("OK"),a.status=f.status}catch(t){this.p(a,"handler_error",t&&t.message),f=a.text("Internal Server Error",500),a.status=500}return e.cache&&"GET"===n.method&&200===f.status&&a.executionCtx.waitUntil(this.S(n,f.clone(),e.cache)),e.compress&&(f=this._(n,f)),this.T(a,e.log),f}};
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -2,20 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
A minimal, high-performance framework for Cloudflare Workers with built-in server-side rendering, routing, auth, rate limiting, caching, and a declarative client-side partial-update library.
|
|
4
4
|
|
|
5
|
-
Inspired by [Hono](https://hono.dev), @lengkapp/edge aims for the same class of performance while shipping with **zero runtime dependencies**.
|
|
5
|
+
Inspired by [Hono](https://hono.dev), @lengkapp/edge aims for the same class of performance while shipping with **zero runtime dependencies**.
|
|
6
|
+
|
|
7
|
+
> **Security:** Server and client are hardened against the OWASP Top 10:2025 and ASVS 5.0 Layer 1 controls. The client sanitizes every response, works under `require-trusted-types-for 'script'` CSP, is nonce-aware for script execution, and sends CSRF tokens on state-changing requests. See [Security](#security-controls).
|
|
6
8
|
|
|
7
9
|
---
|
|
8
10
|
|
|
9
11
|
## Features
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
|
|
13
|
+
### Server
|
|
14
|
+
- **Trie-based routing** – static & dynamic routes (`/users/:id`).
|
|
15
|
+
- **JSX support** – pass JSX straight to `ctx.html(<Card />)`; no build step, no manual `renderToString` call.
|
|
16
|
+
- **Middleware** – auth, rate limiting, CORS, logging, caching, compression, validation.
|
|
17
|
+
- **Cookie helpers** with validation.
|
|
18
|
+
- **Scheduled tasks** via Cron triggers.
|
|
19
|
+
- **Zero dependencies.**
|
|
20
|
+
|
|
21
|
+
### Client
|
|
22
|
+
- **Declarative partial updates** via `_get`, `_post`, `_target`, `_trigger`.
|
|
23
|
+
- **Trusted Types aware** – routes all `innerHTML` through an `edge-policy` sanitizer.
|
|
24
|
+
- **Nonce-aware script execution** – response scripts must match the page's CSP nonce when one exists.
|
|
25
|
+
- **Built-in sanitizer** – strips `<base>`, `<meta refresh>`, `<iframe srcdoc>`, `<object>`, `<embed>`, and `javascript:`/`vbscript:`/`data:text/html` URLs.
|
|
26
|
+
- **CSRF-aware** – sends `X-CSRF-Token` on `POST`/`PUT`/`PATCH`/`DELETE`.
|
|
27
|
+
- **Content-Type guarded** – only `text/html` and `text/plain` responses are inserted.
|
|
28
|
+
|
|
29
|
+
### Security (both)
|
|
30
|
+
- **Prototype-pollution safe** – route params, cookies, JSON bodies, JSX attributes.
|
|
31
|
+
- **XSS-hardened JSX** – no `on*` attributes, no `javascript:` URLs, no malformed tag names.
|
|
32
|
+
- **Structured security logging** – throttled JSON events for auth/rate-limit/handler failures.
|
|
33
|
+
- **Fail-closed middleware** – validation, auth, and rate limit errors deny by default.
|
|
34
|
+
- **Opt-in token hashing** – SHA-256 before KV lookup.
|
|
35
|
+
- **Strict CORS allowlist** – per-origin reflection with `Vary: Origin`.
|
|
19
36
|
|
|
20
37
|
---
|
|
21
38
|
|
|
@@ -25,375 +42,264 @@ Inspired by [Hono](https://hono.dev), @lengkapp/edge aims for the same class of
|
|
|
25
42
|
npm install @lengkapp/edge
|
|
26
43
|
```
|
|
27
44
|
|
|
28
|
-
---
|
|
29
|
-
|
|
30
45
|
## Quick Start
|
|
31
46
|
|
|
32
|
-
|
|
47
|
+
`worker.js`:
|
|
33
48
|
|
|
34
49
|
```ts
|
|
35
|
-
import { Edge
|
|
50
|
+
import { Edge } from '@lengkapp/edge';
|
|
36
51
|
|
|
37
52
|
const app = new Edge();
|
|
38
53
|
|
|
39
54
|
app.get('/', (ctx) => ctx.text('Hello World!'));
|
|
40
|
-
|
|
41
|
-
app.get('/users/:id', (ctx) => {
|
|
42
|
-
return ctx.json({ id: ctx.params.id });
|
|
43
|
-
});
|
|
55
|
+
app.get('/users/:id', (ctx) => ctx.json({ id: ctx.params.id }));
|
|
44
56
|
|
|
45
57
|
export default {
|
|
46
58
|
fetch: (request, env, ctx) => app.fetch(request, env, ctx),
|
|
47
59
|
};
|
|
48
60
|
```
|
|
49
61
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
Cloudflare recommends `wrangler.jsonc` for new projects (TOML is still supported — see [Configuration](#configuration)).
|
|
62
|
+
`wrangler.jsonc`:
|
|
53
63
|
|
|
54
64
|
```jsonc
|
|
55
65
|
{
|
|
56
66
|
"$schema": "./node_modules/wrangler/config-schema.json",
|
|
57
67
|
"name": "my-edge-app",
|
|
58
68
|
"main": "worker.js",
|
|
59
|
-
// Set this to today's date
|
|
60
69
|
"compatibility_date": "2026-09-06",
|
|
61
|
-
"observability": {
|
|
62
|
-
"enabled": true
|
|
63
|
-
}
|
|
70
|
+
"observability": { "enabled": true }
|
|
64
71
|
}
|
|
65
72
|
```
|
|
66
73
|
|
|
67
|
-
|
|
74
|
+
Deploy:
|
|
68
75
|
|
|
69
76
|
```bash
|
|
70
77
|
wrangler deploy
|
|
71
78
|
```
|
|
72
79
|
|
|
73
|
-
> `wrangler publish` was removed in favor of `wrangler deploy`. If you're on an older Wrangler version, run `npm install -g wrangler@latest` first.
|
|
74
|
-
|
|
75
80
|
---
|
|
76
81
|
|
|
77
82
|
## Server API
|
|
78
83
|
|
|
79
|
-
###
|
|
84
|
+
### Context
|
|
85
|
+
|
|
86
|
+
| Member | Description |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `ctx.req` | Incoming Request |
|
|
89
|
+
| `ctx.env` | Environment bindings |
|
|
90
|
+
| `ctx.executionCtx` | ExecutionContext |
|
|
91
|
+
| `ctx.params` | Prototype-safe route params object |
|
|
92
|
+
| `ctx.status` | Default response status (200) |
|
|
93
|
+
| `ctx.headers` | Response Headers |
|
|
94
|
+
| `ctx.query` | URLSearchParams |
|
|
95
|
+
| `ctx.getCookie(name)` | Read a cookie |
|
|
96
|
+
| `ctx.setCookie(name, value, options)` | Set a cookie (name validated) |
|
|
97
|
+
| `ctx.deleteCookie(name, options)` | Delete a cookie |
|
|
98
|
+
| `ctx.text(data, status?, headers?)` | Plain-text response |
|
|
99
|
+
| `ctx.json(data, status?, headers?)` | JSON response |
|
|
100
|
+
| `ctx.html(data, status?, headers?)` | HTML response — accepts a raw string, a JSX element, or an array of JSX elements |
|
|
101
|
+
|
|
102
|
+
Every response carries `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, and a restrictive `Permissions-Policy`.
|
|
103
|
+
|
|
104
|
+
### Route Options
|
|
80
105
|
|
|
81
106
|
```ts
|
|
82
|
-
|
|
83
|
-
|
|
107
|
+
app.get('/protected', { auth: true }, handler);
|
|
108
|
+
app.get('/admin', { auth: { role: 'admin' } }, handler);
|
|
109
|
+
app.get('/scoped', { auth: { scopes: ['read', 'write'] } }, handler);
|
|
110
|
+
app.get('/limited', { rateLimit: { max: 100, window: 60 } }, handler);
|
|
111
|
+
app.get('/cached', { cache: { ttl: 60 } }, handler);
|
|
112
|
+
app.get('/api', { cors: true }, handler);
|
|
113
|
+
app.post('/submit', { validate: (ctx) => /* ... */ true }, handler);
|
|
84
114
|
```
|
|
85
115
|
|
|
86
|
-
|
|
116
|
+
**Auth:** Tokens are looked up in `AUTH_KV`. Send via `Authorization: Bearer <token>` or the `auth_token` cookie. Payloads with an `exp` field (Unix seconds) are rejected when expired.
|
|
87
117
|
|
|
88
|
-
|
|
118
|
+
**CORS:** The default is `origin: '*'`. For production, use a strict allowlist:
|
|
89
119
|
|
|
90
120
|
```ts
|
|
91
|
-
|
|
92
|
-
app.get('/home', handler);
|
|
93
|
-
app.post('/submit', handler);
|
|
94
|
-
|
|
95
|
-
// Dynamic (colon-prefixed segments)
|
|
96
|
-
app.get('/users/:id', (ctx) => {
|
|
97
|
-
const userId = ctx.params.id;
|
|
98
|
-
});
|
|
121
|
+
app.defaults.cors.origin = ['https://app.example.com'];
|
|
99
122
|
```
|
|
100
123
|
|
|
101
|
-
|
|
124
|
+
**Rate limit:** `429` responses carry `Retry-After: 60`.
|
|
102
125
|
|
|
103
|
-
###
|
|
126
|
+
### JSX Support
|
|
104
127
|
|
|
105
|
-
|
|
128
|
+
`ctx.html()` accepts JSX directly — it detects JSX nodes and arrays and renders them automatically. Raw strings are passed through untouched, so you can still serve pre-rendered HTML.
|
|
106
129
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
| `ctx.executionCtx` | The `ExecutionContext` (for `waitUntil`) |
|
|
112
|
-
| `ctx.params` | Route parameters (e.g., `{ id: '123' }`) |
|
|
113
|
-
| `ctx.status` | HTTP status code (default `200`) |
|
|
114
|
-
| `ctx.headers` | `Headers` object for the response |
|
|
115
|
-
| `ctx.query` | `URLSearchParams` (lazy) |
|
|
116
|
-
| `ctx.getCookie(name)` | Get a cookie value |
|
|
117
|
-
| `ctx.setCookie(name, value, options)` | Set a cookie |
|
|
118
|
-
| `ctx.deleteCookie(name, options)` | Delete a cookie (expires immediately) |
|
|
119
|
-
| `ctx.text(data, status?, headers?)` | Return plain text |
|
|
120
|
-
| `ctx.json(data, status?, headers?)` | Return JSON |
|
|
121
|
-
| `ctx.html(data, status?, headers?)` | Return HTML |
|
|
122
|
-
|
|
123
|
-
Cookie options: `path`, `domain`, `maxAge`, `expires`, `secure`, `httpOnly`, `sameSite`.
|
|
124
|
-
|
|
125
|
-
### Returning Responses
|
|
126
|
-
|
|
127
|
-
You can return a `Response` object, a string (becomes text), or an object (automatically JSON).
|
|
128
|
-
If the handler returns a JSX element, it will be rendered to HTML automatically (see [JSX Support](#jsx-support)).
|
|
130
|
+
```tsx
|
|
131
|
+
function Card({ title }) {
|
|
132
|
+
return <div class="card"><h2>{title}</h2></div>;
|
|
133
|
+
}
|
|
129
134
|
|
|
130
|
-
|
|
131
|
-
app.get('/
|
|
132
|
-
|
|
133
|
-
|
|
135
|
+
// Pass JSX straight to ctx.html — no manual renderToString needed.
|
|
136
|
+
app.get('/card', (ctx) => ctx.html(<Card title="Hello" />));
|
|
137
|
+
app.get('/heading', (ctx) => ctx.html(<h1>hello</h1>));
|
|
138
|
+
app.get('/list', (ctx) => ctx.html([<Card title="A" />, <Card title="B" />]));
|
|
134
139
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
});
|
|
140
|
+
// Raw strings still work as before.
|
|
141
|
+
app.get('/raw', (ctx) => ctx.html('<p>pre-rendered</p>'));
|
|
138
142
|
```
|
|
139
143
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
Pass an options object as the second argument (before the handler) to enable middleware.
|
|
143
|
-
|
|
144
|
-
#### Authentication (`auth`)
|
|
144
|
+
`renderToString` is still exported for advanced use cases (for example, embedding rendered HTML inside another response body or email template):
|
|
145
145
|
|
|
146
|
-
|
|
146
|
+
```tsx
|
|
147
|
+
import { renderToString } from '@lengkapp/edge';
|
|
147
148
|
|
|
148
|
-
|
|
149
|
-
app.get('/protected', { auth: true }, handler);
|
|
150
|
-
app.get('/admin', { auth: { role: 'admin' } }, handler);
|
|
151
|
-
app.get('/scoped', { auth: { scopes: ['read', 'write'] } }, handler);
|
|
149
|
+
const html = renderToString(<Card title="Hello" />);
|
|
152
150
|
```
|
|
153
151
|
|
|
154
|
-
|
|
152
|
+
**`renderToString` hardening:**
|
|
153
|
+
- Tag names must match `^[A-Za-z][A-Za-z0-9-]*$`.
|
|
154
|
+
- Attribute names must match `^[A-Za-z_:][A-Za-z0-9_:.-]*$`.
|
|
155
|
+
- `on*` attributes never serialize.
|
|
156
|
+
- `href`/`src`/`action`/`formaction`/`xlink:href` values beginning with `javascript:`, `vbscript:`, or `data:text/html` are stripped.
|
|
157
|
+
- Prototype keys (`__proto__`, `constructor`, `prototype`) are rejected.
|
|
158
|
+
- All string values are HTML-escaped.
|
|
155
159
|
|
|
156
|
-
|
|
160
|
+
---
|
|
157
161
|
|
|
158
|
-
|
|
162
|
+
## Client (Declarative Partial Updates)
|
|
159
163
|
|
|
160
|
-
```
|
|
161
|
-
|
|
164
|
+
```html
|
|
165
|
+
<script src="https://cdn.example.com/edge-client.min.js"></script>
|
|
162
166
|
```
|
|
163
167
|
|
|
164
|
-
|
|
168
|
+
### Attributes
|
|
165
169
|
|
|
166
|
-
|
|
170
|
+
| Attribute | Description |
|
|
171
|
+
|---|---|
|
|
172
|
+
| `_get` / `_post` / `_put` / `_patch` / `_delete` | URL + method |
|
|
173
|
+
| `_target` | CSS selector, or `"this"` |
|
|
174
|
+
| `_trigger` | Event list: `click`, `load`, `visible`, `intersect`, etc. |
|
|
175
|
+
| `_form` | Form ID to serialize as URL-encoded body |
|
|
176
|
+
| `_json` | Comma-separated input names to send as JSON |
|
|
177
|
+
| `_skeleton` | `"false"` disables the loading skeleton |
|
|
178
|
+
| `_retry` | `"false"` disables the retry button |
|
|
167
179
|
|
|
168
|
-
|
|
169
|
-
app.get('/cached', { cache: { ttl: 60, staleWhileRevalidate: 30 } }, handler);
|
|
170
|
-
```
|
|
180
|
+
### Examples
|
|
171
181
|
|
|
172
|
-
|
|
182
|
+
```html
|
|
183
|
+
<button _get="/more-posts" _target="#posts">Load More</button>
|
|
173
184
|
|
|
174
|
-
|
|
185
|
+
<div _get="/user-profile" _target="this"></div>
|
|
175
186
|
|
|
176
|
-
|
|
177
|
-
app.get('/api', { cors: true }, handler);
|
|
187
|
+
<button _post="/login" _json="username,password" _target="#status">Login</button>
|
|
178
188
|
|
|
179
|
-
|
|
180
|
-
app.get('/api', { cors: { origin: 'https://example.com', methods: 'GET,POST' } }, handler);
|
|
189
|
+
<div _get="/lazy" _target="this" _trigger="visible"></div>
|
|
181
190
|
```
|
|
182
191
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
Logs method, URL, and status to the console.
|
|
192
|
+
### How Content Is Inserted
|
|
186
193
|
|
|
187
|
-
|
|
194
|
+
1. Response is fetched with `credentials: 'same-origin'` and `X-Requested-With: XMLHttpRequest`.
|
|
195
|
+
2. `Content-Type` is verified to be `text/html` or `text/plain`.
|
|
196
|
+
3. `res.headers` may contain `x-css-required` / `x-js-required` (with optional `x-css-integrity` / `x-js-integrity` for SRI) — matching resources are injected once.
|
|
197
|
+
4. The HTML is run through `sanitizeHtml`, which removes `<base>`, `<meta refresh>`, `<iframe srcdoc>`, `<object>`, `<embed>`, and neutralizes `javascript:` / `vbscript:` / `data:text/html` URLs.
|
|
198
|
+
5. The sanitized HTML is assigned via `innerHTML` (through a Trusted Types policy named `edge-policy` when available).
|
|
199
|
+
6. Each `<script>` in the result is rebuilt in place so the browser executes it. If the page has a CSP nonce, only scripts carrying the matching nonce run; otherwise all scripts run.
|
|
188
200
|
|
|
189
|
-
|
|
201
|
+
---
|
|
190
202
|
|
|
191
|
-
|
|
203
|
+
## CSRF Protection
|
|
192
204
|
|
|
193
|
-
|
|
205
|
+
The client reads a token from `<meta name="csrf-token" content="...">` or the `XSRF-TOKEN` cookie and sends it as `X-CSRF-Token` on `POST`/`PUT`/`PATCH`/`DELETE`.
|
|
194
206
|
|
|
195
207
|
```ts
|
|
196
208
|
app.post('/submit', {
|
|
197
|
-
validate: (ctx) =>
|
|
209
|
+
validate: (ctx) => {
|
|
210
|
+
const sent = ctx.req.headers.get('X-CSRF-Token');
|
|
211
|
+
return sent && sent === ctx.getCookie('csrf_token');
|
|
212
|
+
}
|
|
198
213
|
}, handler);
|
|
199
214
|
```
|
|
200
215
|
|
|
201
216
|
---
|
|
202
217
|
|
|
203
|
-
##
|
|
218
|
+
## Trusted Types / CSP
|
|
204
219
|
|
|
205
|
-
|
|
220
|
+
If the page enforces `require-trusted-types-for 'script'`, the client creates a policy named `edge-policy` and routes every `innerHTML` through it. The policy runs the built-in sanitizer. To allowlist the policy in CSP:
|
|
206
221
|
|
|
207
|
-
```
|
|
208
|
-
|
|
222
|
+
```
|
|
223
|
+
Content-Security-Policy:
|
|
224
|
+
default-src 'self';
|
|
225
|
+
script-src 'self';
|
|
226
|
+
style-src 'self';
|
|
227
|
+
img-src 'self' data:;
|
|
228
|
+
require-trusted-types-for 'script';
|
|
229
|
+
trusted-types edge-policy;
|
|
230
|
+
frame-ancestors 'none';
|
|
231
|
+
base-uri 'self';
|
|
232
|
+
```
|
|
209
233
|
|
|
210
|
-
|
|
211
|
-
return (
|
|
212
|
-
<div class="card">
|
|
213
|
-
<h2>{title}</h2>
|
|
214
|
-
</div>
|
|
215
|
-
);
|
|
216
|
-
}
|
|
234
|
+
---
|
|
217
235
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
236
|
+
## Security Controls
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
const app = new Edge();
|
|
222
240
|
|
|
223
|
-
|
|
241
|
+
app.defaults.cors.origin = ['https://app.example.com'];
|
|
224
242
|
|
|
225
|
-
|
|
226
|
-
app.
|
|
243
|
+
app.security.hashAuthTokens = true; // SHA-256 token keys, migrate first
|
|
244
|
+
app.security.logSecurityEvents = true; // structured JSON events (default on)
|
|
245
|
+
app.security.extraHeaders = {
|
|
246
|
+
'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
|
|
247
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
248
|
+
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
249
|
+
};
|
|
227
250
|
```
|
|
228
251
|
|
|
252
|
+
### OWASP 2025 Coverage
|
|
253
|
+
|
|
254
|
+
| Category | Mitigation |
|
|
255
|
+
|---|---|
|
|
256
|
+
| A01 Broken Access Control | Auth reasons, expiry enforcement, scope/role checks, strict target resolution |
|
|
257
|
+
| A02 Security Misconfiguration | Safe default headers, strict CORS allowlist, `Vary: Origin` |
|
|
258
|
+
| A03 Supply Chain | Zero deps, SRI honored on injected resources |
|
|
259
|
+
| A04 Crypto Failures | Optional SHA-256 token hashing |
|
|
260
|
+
| A05 Injection / XSS | Prototype-safe objects, JSX attribute sanitization, Trusted Types policy, response sanitizer |
|
|
261
|
+
| A06 Insecure Design | Numeric-sanitized rate limiter, `Retry-After` |
|
|
262
|
+
| A07 Auth Failures | Structured failure reasons, no swallowed errors |
|
|
263
|
+
| A08 Data Integrity | Prototype-safe JSON, `Set-Cookie` stripped from cache |
|
|
264
|
+
| A09 Logging | Structured JSON security events with 60s dedupe |
|
|
265
|
+
| A10 Exceptional Conditions | Fail-closed middleware, no internal leakage |
|
|
266
|
+
|
|
229
267
|
---
|
|
230
268
|
|
|
231
269
|
## Scheduled Tasks
|
|
232
270
|
|
|
233
|
-
Register a scheduled handler for Cron triggers.
|
|
234
|
-
|
|
235
271
|
```ts
|
|
236
272
|
app.scheduled(async (event, env, ctx) => {
|
|
237
273
|
console.log('Cron executed:', event.cron);
|
|
238
274
|
});
|
|
239
|
-
```
|
|
240
|
-
|
|
241
|
-
Then export it in your worker:
|
|
242
275
|
|
|
243
|
-
```ts
|
|
244
276
|
export default {
|
|
245
277
|
fetch: (req, env, ctx) => app.fetch(req, env, ctx),
|
|
246
|
-
scheduled: (event, env, ctx) => app.scheduledHandler?.(event, env, ctx)
|
|
278
|
+
scheduled: (event, env, ctx) => app.scheduledHandler?.(event, env, ctx),
|
|
247
279
|
};
|
|
248
280
|
```
|
|
249
281
|
|
|
250
282
|
---
|
|
251
283
|
|
|
252
|
-
## Client (Declarative Partial Updates)
|
|
253
|
-
|
|
254
|
-
Add the client script to your page (or include it via a CDN):
|
|
255
|
-
|
|
256
|
-
```html
|
|
257
|
-
<script src="https://cdn.example.com/edge-client.min.js"></script>
|
|
258
|
-
```
|
|
259
|
-
|
|
260
|
-
Then use HTML attributes to make elements fetch content asynchronously.
|
|
261
|
-
|
|
262
|
-
### Attributes
|
|
263
|
-
|
|
264
|
-
| Attribute | Description |
|
|
265
|
-
| --- | --- |
|
|
266
|
-
| `_get` | URL to fetch via GET |
|
|
267
|
-
| `_post` | URL to fetch via POST |
|
|
268
|
-
| `_target` | CSS selector for the container to replace. Use `"this"` to replace the element itself. |
|
|
269
|
-
| `_trigger` | Comma-separated list of events that trigger the fetch (default: `click`; `load` if `_target="this"`). Supported: `click`, `load`, `visible`, `intersect`, `submit`, etc. |
|
|
270
|
-
| `_form` | (with `_post`) ID of a form to serialize as the POST body (URL-encoded). |
|
|
271
|
-
| `_json` | (with `_post`) Comma-separated names of input fields to send as JSON. |
|
|
272
|
-
|
|
273
|
-
### Examples
|
|
274
|
-
|
|
275
|
-
**Load content on click:**
|
|
276
|
-
|
|
277
|
-
```html
|
|
278
|
-
<button _get="/more-posts" _target="#posts" _trigger="click">Load More</button>
|
|
279
|
-
<div id="posts"></div>
|
|
280
|
-
```
|
|
281
|
-
|
|
282
|
-
**Auto-load on page load:**
|
|
283
|
-
|
|
284
|
-
```html
|
|
285
|
-
<div _get="/user-profile" _target="this"></div>
|
|
286
|
-
```
|
|
287
|
-
|
|
288
|
-
**Post a form via AJAX:**
|
|
289
|
-
|
|
290
|
-
```html
|
|
291
|
-
<form id="contact-form">
|
|
292
|
-
<input name="email" />
|
|
293
|
-
<button _post="/submit" _form="contact-form" _target="#result">Submit</button>
|
|
294
|
-
</form>
|
|
295
|
-
<div id="result"></div>
|
|
296
|
-
```
|
|
297
|
-
|
|
298
|
-
**Send JSON data:**
|
|
299
|
-
|
|
300
|
-
```html
|
|
301
|
-
<input name="username" />
|
|
302
|
-
<input name="password" />
|
|
303
|
-
<button _post="/login" _json="username,password" _target="#status">Login</button>
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
**Load when element becomes visible (IntersectionObserver):**
|
|
307
|
-
|
|
308
|
-
```html
|
|
309
|
-
<div _get="/lazy-content" _target="this" _trigger="visible"></div>
|
|
310
|
-
```
|
|
311
|
-
|
|
312
|
-
### Skeleton Loading
|
|
313
|
-
|
|
314
|
-
While the request is in progress, the target container is filled with a skeleton loader (three shimmer bars). On error, a "Retry" button is shown.
|
|
315
|
-
|
|
316
|
-
### Resource Injection
|
|
317
|
-
|
|
318
|
-
If the server response includes headers `x-css-required` or `x-js-required`, the client will automatically inject those resources (once per URL) into the page.
|
|
319
|
-
|
|
320
|
-
Example server route:
|
|
321
|
-
|
|
322
|
-
```ts
|
|
323
|
-
app.get('/widget', (ctx) => {
|
|
324
|
-
ctx.headers.set('x-css-required', '["/widget.css"]');
|
|
325
|
-
ctx.headers.set('x-js-required', '["/widget.js"]');
|
|
326
|
-
return ctx.html('<div class="widget">...</div>');
|
|
327
|
-
});
|
|
328
|
-
```
|
|
329
|
-
|
|
330
|
-
---
|
|
331
|
-
|
|
332
284
|
## Configuration
|
|
333
285
|
|
|
334
|
-
###
|
|
335
|
-
|
|
336
|
-
If you use authentication or rate limiting, you need KV namespaces. Create them first:
|
|
286
|
+
### KV Namespaces
|
|
337
287
|
|
|
338
288
|
```bash
|
|
339
289
|
wrangler kv namespace create AUTH_KV
|
|
340
290
|
wrangler kv namespace create RATE_LIMIT_KV
|
|
341
291
|
```
|
|
342
292
|
|
|
343
|
-
Then wire up the returned IDs in your config:
|
|
344
|
-
|
|
345
293
|
```jsonc
|
|
346
294
|
{
|
|
347
|
-
"$schema": "./node_modules/wrangler/config-schema.json",
|
|
348
|
-
"name": "my-edge-app",
|
|
349
|
-
"main": "worker.js",
|
|
350
|
-
// Set this to today's date
|
|
351
|
-
"compatibility_date": "2026-09-06",
|
|
352
|
-
"observability": {
|
|
353
|
-
"enabled": true
|
|
354
|
-
},
|
|
355
295
|
"kv_namespaces": [
|
|
356
|
-
{ "binding": "AUTH_KV", "id": "
|
|
357
|
-
{ "binding": "RATE_LIMIT_KV", "id": "
|
|
358
|
-
]
|
|
359
|
-
// Optional Cron triggers
|
|
360
|
-
"triggers": {
|
|
361
|
-
"crons": ["*/5 * * * *"]
|
|
362
|
-
}
|
|
296
|
+
{ "binding": "AUTH_KV", "id": "..." },
|
|
297
|
+
{ "binding": "RATE_LIMIT_KV", "id": "..." }
|
|
298
|
+
]
|
|
363
299
|
}
|
|
364
300
|
```
|
|
365
301
|
|
|
366
|
-
|
|
367
|
-
<summary>Equivalent <code>wrangler.toml</code></summary>
|
|
368
|
-
|
|
369
|
-
```toml
|
|
370
|
-
name = "my-edge-app"
|
|
371
|
-
main = "worker.js"
|
|
372
|
-
compatibility_date = "2026-09-06"
|
|
373
|
-
|
|
374
|
-
[observability]
|
|
375
|
-
enabled = true
|
|
376
|
-
|
|
377
|
-
[[kv_namespaces]]
|
|
378
|
-
binding = "AUTH_KV"
|
|
379
|
-
id = "your-auth-kv-id"
|
|
380
|
-
|
|
381
|
-
[[kv_namespaces]]
|
|
382
|
-
binding = "RATE_LIMIT_KV"
|
|
383
|
-
id = "your-ratelimit-kv-id"
|
|
384
|
-
|
|
385
|
-
# Optional Cron triggers
|
|
386
|
-
[triggers]
|
|
387
|
-
crons = ["*/5 * * * *"]
|
|
388
|
-
```
|
|
389
|
-
|
|
390
|
-
</details>
|
|
391
|
-
|
|
392
|
-
Wrangler supports both `wrangler.jsonc` and `wrangler.toml` — they configure the same fields, just in different syntax. Don't keep both in the same project. Keep `compatibility_date` current (Cloudflare recommends staying within the last 30 days); see the [compatibility dates docs](https://developers.cloudflare.com/workers/configuration/compatibility-dates/).
|
|
393
|
-
|
|
394
|
-
### Environment Variables
|
|
395
|
-
|
|
396
|
-
The KV binding names are configurable on the Edge instance:
|
|
302
|
+
Rename the bindings on the instance:
|
|
397
303
|
|
|
398
304
|
```ts
|
|
399
305
|
app.authKvBinding = 'CUSTOM_AUTH_KV';
|
|
@@ -402,113 +308,40 @@ app.rateLimitKvBinding = 'CUSTOM_RATE_KV';
|
|
|
402
308
|
|
|
403
309
|
---
|
|
404
310
|
|
|
405
|
-
## Full Example
|
|
406
|
-
|
|
407
|
-
```ts
|
|
408
|
-
import { Edge, renderToString } from '@lengkapp/edge';
|
|
409
|
-
import { HomePage } from './Page.jsx';
|
|
410
|
-
|
|
411
|
-
const app = new Edge();
|
|
412
|
-
|
|
413
|
-
// Basic text
|
|
414
|
-
app.get('/', (ctx) => ctx.text('Hello from Edge!'));
|
|
415
|
-
|
|
416
|
-
// JSX component
|
|
417
|
-
app.get('/home', (ctx) => ctx.html(renderToString(<HomePage />)));
|
|
418
|
-
|
|
419
|
-
// JSON with route params
|
|
420
|
-
app.get('/users/:id', (ctx) => ctx.json({ userId: ctx.params.id }));
|
|
421
|
-
|
|
422
|
-
// POST JSON
|
|
423
|
-
app.post('/users', async (ctx) => {
|
|
424
|
-
const body = await ctx.req.json();
|
|
425
|
-
return ctx.json({ created: true, user: body }, 201);
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
// Auth
|
|
429
|
-
app.get('/protected', { auth: true }, (ctx) => ctx.text('Authenticated'));
|
|
430
|
-
|
|
431
|
-
// Rate limiting
|
|
432
|
-
app.get('/limited', { rateLimit: { max: 5, window: 60 } }, (ctx) => ctx.text('Limited'));
|
|
433
|
-
|
|
434
|
-
// Caching
|
|
435
|
-
app.get('/cached', { cache: { ttl: 60 } }, (ctx) => ctx.text('Cached'));
|
|
436
|
-
|
|
437
|
-
// Cookies
|
|
438
|
-
app.get('/set-cookie', (ctx) => {
|
|
439
|
-
ctx.setCookie('session', 'abc123', { httpOnly: true, path: '/' });
|
|
440
|
-
return ctx.text('Cookie set');
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
app.get('/get-cookie', (ctx) => {
|
|
444
|
-
const session = ctx.getCookie('session');
|
|
445
|
-
return ctx.text(`Cookie: ${session}`);
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
// Scheduled
|
|
449
|
-
app.scheduled(async (event, env, ctx) => {
|
|
450
|
-
console.log('Cron:', event.cron);
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
export default {
|
|
454
|
-
fetch: (req, env, ctx) => app.fetch(req, env, ctx),
|
|
455
|
-
scheduled: (event, env, ctx) => app.scheduledHandler?.(event, env, ctx)
|
|
456
|
-
};
|
|
457
|
-
```
|
|
458
|
-
|
|
459
|
-
---
|
|
460
|
-
|
|
461
311
|
## Performance
|
|
462
312
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
### Test setup
|
|
466
|
-
|
|
467
|
-
- **Tool:** `autocannon` — 10 connections, 10s run per route
|
|
468
|
-
- **Target:** local `wrangler dev` server
|
|
469
|
-
- **Routes:** `GET /text` (plain text) and `GET /json` (JSON)
|
|
470
|
-
- **Contenders:** `@lengkapp/edge`, `hono`, `hono/tiny`, and a native Workers handler with no framework
|
|
313
|
+
Zero runtime dependencies. On a local `wrangler dev` benchmark with `autocannon` (10 connections, 10s per route), throughput sits in the same tier as `hono` / `hono/tiny` / a native worker:
|
|
471
314
|
|
|
472
|
-
|
|
315
|
+
| Framework | `/text` req/s | `/json` req/s |
|
|
316
|
+
|---|---|---|
|
|
317
|
+
| @lengkapp/edge | 503 | 500 |
|
|
318
|
+
| Hono | 506 | 495 |
|
|
319
|
+
| Hono (tiny) | 505 | 498 |
|
|
320
|
+
| Native Workers | 498 | 500 |
|
|
473
321
|
|
|
474
|
-
|
|
322
|
+
The security hardening adds only cheap operations to the request path: prototype-safe objects, two regex checks per JSX attribute, a sanitizer template parse per response (browser-native parser), and a throttled logger. No new async boundaries, no added dependencies, no hashing on the hot path.
|
|
475
323
|
|
|
476
|
-
|
|
477
|
-
| --- | --- | --- | --- | --- | --- |
|
|
478
|
-
| **@lengkapp/edge** | `/text` | 503.1 | 19.38 ms | 5,000 / 10.03s | 1.38 MB |
|
|
479
|
-
| **@lengkapp/edge** | `/json` | **500.4** | 19.47 ms | 5,000 / 10.03s | 1.42 MB |
|
|
480
|
-
| Hono | `/text` | **506.0** | 19.27 ms | 5,000 / 10.03s | 455 kB |
|
|
481
|
-
| Hono | `/json` | 495.2 | 19.69 ms | 5,000 / 10.03s | 426 kB |
|
|
482
|
-
| Hono (`hono/tiny`) | `/text` | 505.5 | 19.28 ms | 5,000 / 10.03s | 455 kB |
|
|
483
|
-
| Hono (`hono/tiny`) | `/json` | 497.9 | 19.57 ms | 5,000 / 10.03s | 428 kB |
|
|
484
|
-
| Native Workers (no framework) | `/text` | 498.4 | 19.55 ms | 5,000 / 10.02s | 379 kB |
|
|
485
|
-
| Native Workers (no framework) | `/json` | 499.7 | 19.51 ms | 5,000 / 10.03s | 430 kB |
|
|
324
|
+
The JSX renderer itself is tuned for throughput: single-pass escaping (`charCodeAt` scan with a fast no-op path), direct string concatenation instead of intermediate arrays, cached camelCase → kebab-case conversions for style objects, and a `Set`-based URL-attribute lookup on the hot path. `ctx.html(<Card />)` skips the redundant string-identity check that a manual `renderToString(<Card />)` call would still hit.
|
|
486
325
|
|
|
487
|
-
|
|
488
|
-
xychart-beta
|
|
489
|
-
title "Avg requests/sec — GET /text"
|
|
490
|
-
x-axis ["@lengkapp/edge", "Hono", "Hono (tiny)", "Native Workers"]
|
|
491
|
-
y-axis "Req/sec" 490 --> 510
|
|
492
|
-
bar [503.1, 506.0, 505.5, 498.4]
|
|
493
|
-
```
|
|
494
|
-
|
|
495
|
-
```mermaid
|
|
496
|
-
xychart-beta
|
|
497
|
-
title "Avg requests/sec — GET /json"
|
|
498
|
-
x-axis ["@lengkapp/edge", "Hono", "Hono (tiny)", "Native Workers"]
|
|
499
|
-
y-axis "Req/sec" 490 --> 505
|
|
500
|
-
bar [500.4, 495.2, 497.9, 499.7]
|
|
501
|
-
```
|
|
502
|
-
|
|
503
|
-
### Takeaways
|
|
326
|
+
---
|
|
504
327
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
328
|
+
## Security Posture Summary
|
|
329
|
+
|
|
330
|
+
| Layer | Mechanism |
|
|
331
|
+
|---|---|
|
|
332
|
+
| HTML insertion | `sanitizeHtml` → `innerHTML` via Trusted Types policy `edge-policy` |
|
|
333
|
+
| Dangerous elements | `<base>`, `<meta refresh>`, `<iframe srcdoc>`, `<object>`, `<embed>`, `form[action^="javascript:"]` removed |
|
|
334
|
+
| Dangerous URLs | `javascript:`, `vbscript:`, `data:text/html` removed from `href`/`src`/`action`/`formaction`/`xlink:href`/`data` |
|
|
335
|
+
| Script execution | Nonce-gated when the page uses CSP nonces; deduplicated by `src`; inline scripts block-wrapped |
|
|
336
|
+
| CSRF | `X-CSRF-Token` from meta or cookie on state-changing methods |
|
|
337
|
+
| Cookies | `credentials: 'same-origin'`; names validated on the server |
|
|
338
|
+
| Server response headers | `nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy` |
|
|
339
|
+
| Server middleware | Fail-closed; structured security logs; strict CORS allowlist; optional token hashing |
|
|
340
|
+
| Prototype pollution | `Object.create(null)` + forbidden-key filtering in params, cookies, JSON, JSX |
|
|
341
|
+
| Build | Reserved exports/properties, `keep_quoted: "strict"`, per-bundle post-minification self-test |
|
|
509
342
|
|
|
510
343
|
---
|
|
511
344
|
|
|
512
345
|
## License
|
|
513
346
|
|
|
514
|
-
See
|
|
347
|
+
See LICENSE. © LengkApp — Yasir Haris
|