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