@lengkapp/edge 0.0.1
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/edge-client.js +279 -0
- package/edge-client.min.js +1 -0
- package/edge-server.js +488 -0
- package/edge-server.min.js +12 -0
- package/package.json +34 -0
- package/readme.md +118 -0
package/edge-client.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const d = document,
|
|
5
|
+
body = d.body,
|
|
6
|
+
head = d.head;
|
|
7
|
+
|
|
8
|
+
// ---------- Constants and caches ----------
|
|
9
|
+
const VALID_EVENTS = new Set([
|
|
10
|
+
'click', 'dblclick', 'mousedown', 'mouseup', 'mouseover', 'mouseout',
|
|
11
|
+
'mouseenter', 'mouseleave', 'mousemove', 'contextmenu',
|
|
12
|
+
'focus', 'blur', 'focusin', 'focusout',
|
|
13
|
+
'keydown', 'keyup', 'keypress',
|
|
14
|
+
'change', 'input', 'submit', 'reset',
|
|
15
|
+
'load', 'DOMContentLoaded', 'ready',
|
|
16
|
+
'scroll', 'resize', 'wheel', 'touchstart', 'touchend', 'touchmove',
|
|
17
|
+
'visible', 'intersect'
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
// WeakMaps for per‑element caches
|
|
21
|
+
const triggerCache = new WeakMap(); // element -> Set of event names (lowercase)
|
|
22
|
+
const targetCache = new WeakMap(); // element -> target DOM node (resolved)
|
|
23
|
+
const resourceCache = new Set(); // already injected CSS/JS URLs
|
|
24
|
+
|
|
25
|
+
// ---------- Injected base styles ----------
|
|
26
|
+
const injectBaseStyles = () => {
|
|
27
|
+
if (d.getElementById('df-style')) return;
|
|
28
|
+
const style = d.createElement('style');
|
|
29
|
+
style.id = 'df-style';
|
|
30
|
+
style.textContent = `
|
|
31
|
+
.df-skeleton { display:flex; flex-direction:column; gap:8px; padding:10px; }
|
|
32
|
+
.df-skeleton .df-bar {
|
|
33
|
+
height:12px; background:linear-gradient(90deg,#e0e0e0 25%,#f0f0f0 50%,#e0e0e0 75%);
|
|
34
|
+
background-size:200% 100%; animation:df-shimmer 1.5s infinite; border-radius:4px;
|
|
35
|
+
}
|
|
36
|
+
@keyframes df-shimmer { 0%{background-position:-200% 0} 100%{background-position:200% 0} }
|
|
37
|
+
.df-retry {
|
|
38
|
+
display:inline-block; padding:8px 16px; background:#007bff; color:#fff;
|
|
39
|
+
border-radius:4px; cursor:pointer; text-decoration:none; font-size:14px;
|
|
40
|
+
}
|
|
41
|
+
.df-retry:hover { background:#0056b3; }
|
|
42
|
+
`;
|
|
43
|
+
head.appendChild(style);
|
|
44
|
+
};
|
|
45
|
+
injectBaseStyles();
|
|
46
|
+
|
|
47
|
+
// ---------- Pre‑built templates (cloned on use) ----------
|
|
48
|
+
const skeletonTemplate = d.createElement('template');
|
|
49
|
+
skeletonTemplate.innerHTML = `
|
|
50
|
+
<div class="df-skeleton">
|
|
51
|
+
<div class="df-bar"></div>
|
|
52
|
+
<div class="df-bar"></div>
|
|
53
|
+
<div class="df-bar"></div>
|
|
54
|
+
</div>`;
|
|
55
|
+
const retryTemplate = d.createElement('template');
|
|
56
|
+
retryTemplate.innerHTML = `<span class="df-retry">Retry</span>`;
|
|
57
|
+
|
|
58
|
+
// ---------- Resource helpers ----------
|
|
59
|
+
const parseHeaderList = (str) => {
|
|
60
|
+
if (!str) return [];
|
|
61
|
+
return str.replace(/^\[|\]$/g, '').split(',')
|
|
62
|
+
.map(s => s.trim().replace(/^['"]|['"]$/g, ''))
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const injectResources = (resources, type) => {
|
|
67
|
+
resources.forEach(url => {
|
|
68
|
+
if (resourceCache.has(url)) return;
|
|
69
|
+
resourceCache.add(url);
|
|
70
|
+
if (type === 'css') {
|
|
71
|
+
const link = d.createElement('link');
|
|
72
|
+
link.rel = 'stylesheet'; link.href = url;
|
|
73
|
+
head.appendChild(link);
|
|
74
|
+
} else if (type === 'js') {
|
|
75
|
+
const script = d.createElement('script');
|
|
76
|
+
script.src = url;
|
|
77
|
+
body.appendChild(script);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// ---------- Visibility helpers ----------
|
|
83
|
+
const isElementActuallyVisible = (el) => {
|
|
84
|
+
const style = getComputedStyle(el);
|
|
85
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
|
86
|
+
const rect = el.getBoundingClientRect();
|
|
87
|
+
return rect.width > 0 && rect.height > 0;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Shared IntersectionObserver
|
|
91
|
+
let intersectionObserver = null;
|
|
92
|
+
const getIO = () => {
|
|
93
|
+
if (!intersectionObserver) {
|
|
94
|
+
intersectionObserver = new IntersectionObserver((entries) => {
|
|
95
|
+
entries.forEach(entry => {
|
|
96
|
+
const el = entry.target;
|
|
97
|
+
const visible = entry.isIntersecting && isElementActuallyVisible(el);
|
|
98
|
+
if (visible && el.dataset.wasVisible !== 'true') {
|
|
99
|
+
run(el);
|
|
100
|
+
}
|
|
101
|
+
el.dataset.wasVisible = visible ? 'true' : 'false';
|
|
102
|
+
});
|
|
103
|
+
}, { threshold: 0 });
|
|
104
|
+
}
|
|
105
|
+
return intersectionObserver;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// ---------- Core run function ----------
|
|
109
|
+
const run = async (el) => {
|
|
110
|
+
if (el.dataset.running === 'true') return;
|
|
111
|
+
el.dataset.running = 'true';
|
|
112
|
+
|
|
113
|
+
const post = el.hasAttribute('_post');
|
|
114
|
+
const url = el.getAttribute('_post') || el.getAttribute('_get');
|
|
115
|
+
let bodyData, headers = {};
|
|
116
|
+
|
|
117
|
+
if (post) {
|
|
118
|
+
const formId = el.getAttribute('_form');
|
|
119
|
+
const json = el.getAttribute('_json');
|
|
120
|
+
if (formId) {
|
|
121
|
+
const form = d.getElementById(formId);
|
|
122
|
+
if (form) bodyData = new URLSearchParams(new FormData(form));
|
|
123
|
+
} else if (json) {
|
|
124
|
+
bodyData = JSON.stringify(Object.fromEntries(
|
|
125
|
+
json.split(',').map(name => {
|
|
126
|
+
const input = d.querySelector(`[name="${name}"]`);
|
|
127
|
+
return [name, input ? input.value : ''];
|
|
128
|
+
})
|
|
129
|
+
));
|
|
130
|
+
headers['Content-Type'] = 'application/json';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Resolve target (cached)
|
|
135
|
+
const targetSelector = el.getAttribute('_target');
|
|
136
|
+
let container = targetCache.get(el);
|
|
137
|
+
if (!container) {
|
|
138
|
+
container = targetSelector === 'this' ? el : d.querySelector(targetSelector);
|
|
139
|
+
if (container) targetCache.set(el, container);
|
|
140
|
+
}
|
|
141
|
+
if (!container) {
|
|
142
|
+
console.warn('Target not found:', targetSelector);
|
|
143
|
+
el.dataset.running = 'false';
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Show skeleton (clone template)
|
|
148
|
+
container.replaceChildren(skeletonTemplate.content.cloneNode(true));
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const res = await fetch(url, { method: post ? 'POST' : 'GET', body: bodyData, headers });
|
|
152
|
+
|
|
153
|
+
// Inject resources from headers
|
|
154
|
+
const css = res.headers.get('x-css-required') || res.headers.get('x-css-requiered');
|
|
155
|
+
const js = res.headers.get('x-js-required');
|
|
156
|
+
if (css) injectResources(parseHeaderList(css), 'css');
|
|
157
|
+
if (js) injectResources(parseHeaderList(js), 'js');
|
|
158
|
+
|
|
159
|
+
const text = await res.text();
|
|
160
|
+
container.innerHTML = text; // MutationObserver picks up new declarative elements
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.error(err);
|
|
163
|
+
// Show retry (clone template)
|
|
164
|
+
const retry = retryTemplate.content.firstElementChild.cloneNode(true);
|
|
165
|
+
retry.addEventListener('click', (e) => {
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
e.stopPropagation();
|
|
168
|
+
run(el);
|
|
169
|
+
});
|
|
170
|
+
container.replaceChildren(retry);
|
|
171
|
+
} finally {
|
|
172
|
+
el.dataset.running = 'false';
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// ---------- Trigger parsing and caching ----------
|
|
177
|
+
const getTriggerEvents = (el) => {
|
|
178
|
+
let events = triggerCache.get(el);
|
|
179
|
+
if (events) return events;
|
|
180
|
+
events = new Set();
|
|
181
|
+
const attr = el.getAttribute('_trigger');
|
|
182
|
+
if (attr && attr.trim() !== '') {
|
|
183
|
+
attr.split(',').forEach(s => {
|
|
184
|
+
const evt = s.trim().toLowerCase();
|
|
185
|
+
if (VALID_EVENTS.has(evt)) events.add(evt);
|
|
186
|
+
});
|
|
187
|
+
} else {
|
|
188
|
+
// Default: click (plus load if _target="this")
|
|
189
|
+
events.add('click');
|
|
190
|
+
if (el.getAttribute('_target') === 'this') events.add('load');
|
|
191
|
+
}
|
|
192
|
+
triggerCache.set(el, events);
|
|
193
|
+
return events;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// ---------- Element initialisation (no per‑element listeners) ----------
|
|
197
|
+
const initElement = (el) => {
|
|
198
|
+
if (el.dataset.initialized === 'true') return;
|
|
199
|
+
el.dataset.initialized = 'true';
|
|
200
|
+
|
|
201
|
+
const events = getTriggerEvents(el);
|
|
202
|
+
|
|
203
|
+
// Handle load / ready immediately
|
|
204
|
+
if (events.has('load') || events.has('domcontentloaded') || events.has('ready')) {
|
|
205
|
+
run(el);
|
|
206
|
+
}
|
|
207
|
+
// Set up visibility observation
|
|
208
|
+
if (events.has('visible') || events.has('intersect')) {
|
|
209
|
+
el.dataset.wasVisible = 'false';
|
|
210
|
+
getIO().observe(el);
|
|
211
|
+
}
|
|
212
|
+
// All other events are handled by the global delegated listener
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// ---------- Global event delegation ----------
|
|
216
|
+
const delegatedEvents = new Set([
|
|
217
|
+
'click', 'dblclick', 'mousedown', 'mouseup', 'mouseover', 'mouseout',
|
|
218
|
+
'mouseenter', 'mouseleave', 'mousemove', 'contextmenu',
|
|
219
|
+
'focus', 'blur', 'focusin', 'focusout',
|
|
220
|
+
'keydown', 'keyup', 'keypress',
|
|
221
|
+
'change', 'input', 'submit', 'reset',
|
|
222
|
+
'scroll', 'resize', 'wheel', 'touchstart', 'touchend', 'touchmove'
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
// Attach one listener per event type, passive for scroll‑like events
|
|
226
|
+
delegatedEvents.forEach(evt => {
|
|
227
|
+
const isPassive = ['scroll', 'touchstart', 'touchmove', 'touchend', 'wheel'].includes(evt);
|
|
228
|
+
d.addEventListener(evt, (e) => {
|
|
229
|
+
const target = e.target;
|
|
230
|
+
if (!(target instanceof Element)) return;
|
|
231
|
+
const el = target.closest('[_get], [_post]');
|
|
232
|
+
if (!el) return;
|
|
233
|
+
const events = getTriggerEvents(el);
|
|
234
|
+
if (events.has(evt)) {
|
|
235
|
+
if (evt === 'click') e.preventDefault();
|
|
236
|
+
run(el);
|
|
237
|
+
}
|
|
238
|
+
}, isPassive ? { passive: true } : false);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ---------- MutationObserver (batch processing) ----------
|
|
242
|
+
let mutationQueue = [];
|
|
243
|
+
let mutationScheduled = false;
|
|
244
|
+
|
|
245
|
+
const processMutations = () => {
|
|
246
|
+
mutationScheduled = false;
|
|
247
|
+
const nodes = mutationQueue;
|
|
248
|
+
mutationQueue = [];
|
|
249
|
+
nodes.forEach(node => {
|
|
250
|
+
if (node.nodeType !== 1) return;
|
|
251
|
+
if (node.matches('[_get], [_post]')) initElement(node);
|
|
252
|
+
node.querySelectorAll('[_get], [_post]').forEach(initElement);
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const mo = new MutationObserver((mutations) => {
|
|
257
|
+
mutations.forEach(m => {
|
|
258
|
+
m.addedNodes.forEach(node => {
|
|
259
|
+
if (node.nodeType === 1) mutationQueue.push(node);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
if (!mutationScheduled) {
|
|
263
|
+
mutationScheduled = true;
|
|
264
|
+
Promise.resolve().then(processMutations);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
mo.observe(body, { childList: true, subtree: true });
|
|
268
|
+
|
|
269
|
+
// ---------- Initialisation on DOM ready ----------
|
|
270
|
+
const initAll = () => {
|
|
271
|
+
d.querySelectorAll('[_get], [_post]').forEach(initElement);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
if (d.readyState === 'loading') {
|
|
275
|
+
d.addEventListener('DOMContentLoaded', initAll, { once: true });
|
|
276
|
+
} else {
|
|
277
|
+
initAll();
|
|
278
|
+
}
|
|
279
|
+
})();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(()=>{'use strict';const d=document,b=d.body,h=d.head,V=new Set(['click','dblclick','mousedown','mouseup','mouseover','mouseout','mouseenter','mouseleave','mousemove','contextmenu','focus','blur','focusin','focusout','keydown','keyup','keypress','change','input','submit','reset','load','DOMContentLoaded','ready','scroll','resize','wheel','touchstart','touchend','touchmove','visible','intersect']),T=new WeakMap,C=new WeakMap,R=new Set;if(!d.getElementById('s')){let s=d.createElement('style');s.id='s';s.textContent='.sk{display:flex;flex-direction:column;gap:8px;padding:10px}.sk .b{height:12px;background:linear-gradient(90deg,#e0e0e0 25%,#f0f0f0 50%,#e0e0e0 75%);background-size:200% 100%;animation:sh 1.5s infinite;border-radius:4px}@keyframes sh{0%{background-position:-200% 0}100%{background-position:200% 0}}.rt{display:inline-block;padding:8px 16px;background:#007bff;color:#fff;border-radius:4px;cursor:pointer;text-decoration:none;font-size:14px}.rt:hover{background:#0056b3}';h.appendChild(s)}let skT=d.createElement('template');skT.innerHTML='<div class="sk"><div class="b"></div><div class="b"></div><div class="b"></div></div>';let rtT=d.createElement('template');rtT.innerHTML='<span class="rt">Retry</span>';const phl=s=>{if(!s)return[];return s.replace(/^\[|\]$/g,'').split(',').map(x=>x.trim().replace(/^['"]|['"]$/g,'')).filter(Boolean)},inj=(r,t)=>{r.forEach(u=>{if(R.has(u))return;R.add(u);if(t==='css'){let l=d.createElement('link');l.rel='stylesheet';l.href=u;h.appendChild(l)}else if(t==='js'){let s=d.createElement('script');s.src=u;b.appendChild(s)}})},vis=el=>{let st=getComputedStyle(el);if(st.display==='none'||st.visibility==='hidden'||st.opacity==='0')return false;let r=el.getBoundingClientRect();return r.width>0&&r.height>0};let io=null;const gio=()=>{if(!io){io=new IntersectionObserver(es=>{es.forEach(e=>{let el=e.target,v=e.isIntersecting&&vis(el);if(v&&el.dataset.wasVisible!=='true')run(el);el.dataset.wasVisible=v?'true':'false'})},{threshold:0})}return io};const run=async el=>{if(el.dataset.running==='true')return;el.dataset.running='true';let post=el.hasAttribute('_post'),url=el.getAttribute('_post')||el.getAttribute('_get'),bd,hd={};if(post){let fid=el.getAttribute('_form'),js=el.getAttribute('_json');if(fid){let f=d.getElementById(fid);if(f)bd=new URLSearchParams(new FormData(f))}else if(js){bd=JSON.stringify(Object.fromEntries(js.split(',').map(n=>{let i=d.querySelector(`[name="${n}"]`);return[n,i?i.value:'']})));hd['Content-Type']='application/json'}}let ts=el.getAttribute('_target'),ct=C.get(el);if(!ct){ct=ts==='this'?el:d.querySelector(ts);if(ct)C.set(el,ct)}if(!ct){console.warn('Target not found:',ts);el.dataset.running='false';return}ct.replaceChildren(skT.content.cloneNode(true));try{let res=await fetch(url,{method:post?'POST':'GET',body:bd,headers:hd});let css=res.headers.get('x-css-required')||res.headers.get('x-css-requiered'),js=res.headers.get('x-js-required');if(css)inj(phl(css),'css');if(js)inj(phl(js),'js');ct.innerHTML=await res.text()}catch(e){console.error(e);let rt=rtT.content.firstElementChild.cloneNode(true);rt.addEventListener('click',ev=>{ev.preventDefault();ev.stopPropagation();run(el)});ct.replaceChildren(rt)}finally{el.dataset.running='false'}};const gte=el=>{let ev=T.get(el);if(ev)return ev;ev=new Set;let a=el.getAttribute('_trigger');if(a&&a.trim()){a.split(',').forEach(s=>{let e=s.trim().toLowerCase();if(V.has(e))ev.add(e)})}else{ev.add('click');if(el.getAttribute('_target')==='this')ev.add('load')}T.set(el,ev);return ev};const init=el=>{if(el.dataset.initialized==='true')return;el.dataset.initialized='true';let evs=gte(el);if(evs.has('load')||evs.has('domcontentloaded')||evs.has('ready'))run(el);if(evs.has('visible')||evs.has('intersect')){el.dataset.wasVisible='false';gio().observe(el)}};const delegated=['click','dblclick','mousedown','mouseup','mouseover','mouseout','mouseenter','mouseleave','mousemove','contextmenu','focus','blur','focusin','focusout','keydown','keyup','keypress','change','input','submit','reset','scroll','resize','wheel','touchstart','touchend','touchmove'];delegated.forEach(ev=>{let pass=['scroll','touchstart','touchmove','touchend','wheel'].includes(ev);d.addEventListener(ev,e=>{let t=e.target;if(!(t instanceof Element))return;let el=t.closest('[_get],[_post]');if(!el)return;let evs=gte(el);if(evs.has(ev)){if(ev==='click')e.preventDefault();run(el)}},pass?{passive:true}:false)});let mq=[],ms=false;const pm=()=>{ms=false;let nodes=mq;mq=[];nodes.forEach(n=>{if(n.nodeType!==1)return;if(n.matches('[_get],[_post]'))init(n);n.querySelectorAll('[_get],[_post]').forEach(init)})};new MutationObserver(ms=>{ms.forEach(m=>m.addedNodes.forEach(n=>{if(n.nodeType===1)mq.push(n)}));if(!ms){ms=true;Promise.resolve().then(pm)}}).observe(b,{childList:true,subtree:true});const initAll=()=>d.querySelectorAll('[_get],[_post]').forEach(init);if(d.readyState==='loading')d.addEventListener('DOMContentLoaded',initAll,{once:true});else initAll()})();
|
package/edge-server.js
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
class Context {
|
|
2
|
+
constructor(request, env, executionCtx, params = {}, parsedUrl = null) {
|
|
3
|
+
this.req = request;
|
|
4
|
+
this.env = env;
|
|
5
|
+
this.executionCtx = executionCtx;
|
|
6
|
+
this.params = params;
|
|
7
|
+
this.status = 200;
|
|
8
|
+
this.headers = new Headers();
|
|
9
|
+
this._rawCookie = request.headers.get('Cookie') || '';
|
|
10
|
+
this._cookies = null; // lazy parsed
|
|
11
|
+
this.url = parsedUrl || new URL(request.url); // reuse pre-parsed URL if provided
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Lazy cookie parsing
|
|
15
|
+
_ensureCookies() {
|
|
16
|
+
if (this._cookies === null) {
|
|
17
|
+
const cookies = {};
|
|
18
|
+
if (this._rawCookie) {
|
|
19
|
+
for (const pair of this._rawCookie.split(';')) {
|
|
20
|
+
const trimmed = pair.trim();
|
|
21
|
+
if (!trimmed) continue;
|
|
22
|
+
const idx = trimmed.indexOf('=');
|
|
23
|
+
if (idx > 0) {
|
|
24
|
+
const name = decodeURIComponent(trimmed.slice(0, idx));
|
|
25
|
+
const value = decodeURIComponent(trimmed.slice(idx + 1));
|
|
26
|
+
cookies[name] = value;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
this._cookies = cookies;
|
|
31
|
+
}
|
|
32
|
+
return this._cookies;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
getCookie(name) {
|
|
36
|
+
return this._ensureCookies()[name] ?? null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Lazy query parsing
|
|
40
|
+
get query() {
|
|
41
|
+
return this.url.searchParams;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setCookie(name, value, options = {}) {
|
|
45
|
+
let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
|
|
46
|
+
if (options.path) cookie += `; Path=${options.path}`;
|
|
47
|
+
if (options.domain) cookie += `; Domain=${options.domain}`;
|
|
48
|
+
if (options.maxAge !== undefined) cookie += `; Max-Age=${options.maxAge}`;
|
|
49
|
+
if (options.expires) cookie += `; Expires=${options.expires.toUTCString()}`;
|
|
50
|
+
if (options.secure) cookie += `; Secure`;
|
|
51
|
+
if (options.httpOnly) cookie += `; HttpOnly`;
|
|
52
|
+
if (options.sameSite) cookie += `; SameSite=${options.sameSite}`;
|
|
53
|
+
this.headers.append('Set-Cookie', cookie);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
deleteCookie(name, options = {}) {
|
|
57
|
+
this.setCookie(name, '', { ...options, maxAge: 0, expires: new Date(0) });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
text(data, status = this.status, headers = {}) {
|
|
61
|
+
const h = this._buildHeaders(headers);
|
|
62
|
+
h.set('Content-Type', 'text/plain');
|
|
63
|
+
return new Response(data, { status, headers: h });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
json(data, status = this.status, headers = {}) {
|
|
67
|
+
const h = this._buildHeaders(headers);
|
|
68
|
+
h.set('Content-Type', 'application/json');
|
|
69
|
+
return new Response(JSON.stringify(data), { status, headers: h });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
html(data, status = this.status, headers = {}) {
|
|
73
|
+
const h = this._buildHeaders(headers);
|
|
74
|
+
h.set('Content-Type', 'text/html');
|
|
75
|
+
return new Response(data, { status, headers: h });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_buildHeaders(headers) {
|
|
79
|
+
if (Object.keys(headers).length === 0) {
|
|
80
|
+
return this.headers;
|
|
81
|
+
}
|
|
82
|
+
const h = new Headers(this.headers);
|
|
83
|
+
for (const key in headers) {
|
|
84
|
+
if (Object.prototype.hasOwnProperty.call(headers, key)) {
|
|
85
|
+
h.set(key, headers[key]);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return h;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const Fragment = Symbol('Fragment');
|
|
93
|
+
|
|
94
|
+
export function jsx(type, props, ...children) {
|
|
95
|
+
const normalizedProps = props || {};
|
|
96
|
+
const flatChildren = children.flat(Infinity);
|
|
97
|
+
return {
|
|
98
|
+
type,
|
|
99
|
+
props: normalizedProps,
|
|
100
|
+
children: flatChildren,
|
|
101
|
+
__isJSX: true,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function renderToString(node) {
|
|
106
|
+
if (node == null || typeof node === 'boolean') return '';
|
|
107
|
+
if (typeof node === 'string' || typeof node === 'number') {
|
|
108
|
+
return escapeHtml(String(node));
|
|
109
|
+
}
|
|
110
|
+
if (Array.isArray(node)) {
|
|
111
|
+
const parts = [];
|
|
112
|
+
for (let i = 0; i < node.length; i++) {
|
|
113
|
+
parts.push(renderToString(node[i]));
|
|
114
|
+
}
|
|
115
|
+
return parts.join('');
|
|
116
|
+
}
|
|
117
|
+
if (!node.__isJSX) {
|
|
118
|
+
return escapeHtml(String(node));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const { type, props, children } = node;
|
|
122
|
+
|
|
123
|
+
if (type === Fragment) {
|
|
124
|
+
const parts = [];
|
|
125
|
+
for (let i = 0; i < children.length; i++) {
|
|
126
|
+
parts.push(renderToString(children[i]));
|
|
127
|
+
}
|
|
128
|
+
return parts.join('');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (typeof type === 'symbol') {
|
|
132
|
+
return '';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (typeof type === 'function') {
|
|
136
|
+
const componentResult = type({ ...props, children });
|
|
137
|
+
return renderToString(componentResult);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const attrsParts = [];
|
|
141
|
+
for (const key in props) {
|
|
142
|
+
if (key === 'children') continue;
|
|
143
|
+
const value = props[key];
|
|
144
|
+
if (value == null || value === false) continue;
|
|
145
|
+
if (key === 'className') {
|
|
146
|
+
attrsParts.push(` class="${escapeHtml(value)}"`);
|
|
147
|
+
} else if (key === 'htmlFor') {
|
|
148
|
+
attrsParts.push(` for="${escapeHtml(value)}"`);
|
|
149
|
+
} else if (key.startsWith('on') && typeof value === 'function') {
|
|
150
|
+
continue;
|
|
151
|
+
} else if (key === 'style' && typeof value === 'object') {
|
|
152
|
+
const styleStr = Object.entries(value)
|
|
153
|
+
.map(([prop, val]) => `${camelToKebab(prop)}:${val}`)
|
|
154
|
+
.join(';');
|
|
155
|
+
attrsParts.push(` style="${escapeHtml(styleStr)}"`);
|
|
156
|
+
} else if (value === true) {
|
|
157
|
+
attrsParts.push(` ${key}`);
|
|
158
|
+
} else {
|
|
159
|
+
attrsParts.push(` ${key}="${escapeHtml(String(value))}"`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const attrs = attrsParts.join('');
|
|
163
|
+
|
|
164
|
+
const childParts = [];
|
|
165
|
+
for (let i = 0; i < children.length; i++) {
|
|
166
|
+
childParts.push(renderToString(children[i]));
|
|
167
|
+
}
|
|
168
|
+
const innerHTML = childParts.join('');
|
|
169
|
+
|
|
170
|
+
return `<${type}${attrs}>${innerHTML}</${type}>`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const HTML_ESCAPE_MAP = {
|
|
174
|
+
'&': '&',
|
|
175
|
+
'<': '<',
|
|
176
|
+
'>': '>',
|
|
177
|
+
'"': '"',
|
|
178
|
+
"'": '''
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
function escapeHtml(str) {
|
|
182
|
+
return str.replace(/[&<>"']/g, char => HTML_ESCAPE_MAP[char]);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function camelToKebab(str) {
|
|
186
|
+
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------- Trie-based router for dynamic routes ----------
|
|
190
|
+
class TrieNode {
|
|
191
|
+
constructor() {
|
|
192
|
+
this.children = new Map(); // exact segment -> TrieNode
|
|
193
|
+
this.paramChild = null; // node for :param
|
|
194
|
+
this.paramName = null; // param name if paramChild exists
|
|
195
|
+
this.handler = null; // { handler, options }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
class RouteTrie {
|
|
200
|
+
constructor() {
|
|
201
|
+
this.root = new TrieNode();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
add(path, handler, options) {
|
|
205
|
+
const segments = path.split('/').filter(Boolean);
|
|
206
|
+
let node = this.root;
|
|
207
|
+
for (const seg of segments) {
|
|
208
|
+
if (seg.startsWith(':')) {
|
|
209
|
+
if (!node.paramChild) {
|
|
210
|
+
node.paramChild = new TrieNode();
|
|
211
|
+
node.paramName = seg.slice(1);
|
|
212
|
+
}
|
|
213
|
+
node = node.paramChild;
|
|
214
|
+
} else {
|
|
215
|
+
if (!node.children.has(seg)) {
|
|
216
|
+
node.children.set(seg, new TrieNode());
|
|
217
|
+
}
|
|
218
|
+
node = node.children.get(seg);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
node.handler = { handler, options };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
match(path) {
|
|
225
|
+
const segments = path.split('/').filter(Boolean);
|
|
226
|
+
let node = this.root;
|
|
227
|
+
const params = {};
|
|
228
|
+
for (const seg of segments) {
|
|
229
|
+
if (node.children.has(seg)) {
|
|
230
|
+
node = node.children.get(seg);
|
|
231
|
+
} else if (node.paramChild) {
|
|
232
|
+
params[node.paramName] = seg;
|
|
233
|
+
node = node.paramChild;
|
|
234
|
+
} else {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (node.handler) {
|
|
239
|
+
return {
|
|
240
|
+
handler: node.handler.handler,
|
|
241
|
+
options: node.handler.options,
|
|
242
|
+
params
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD'];
|
|
250
|
+
|
|
251
|
+
export class Edge {
|
|
252
|
+
constructor() {
|
|
253
|
+
// Static routes: nested map method -> path -> handler
|
|
254
|
+
this.staticRoutes = new Map();
|
|
255
|
+
for (const m of HTTP_METHODS) {
|
|
256
|
+
this.staticRoutes.set(m, new Map());
|
|
257
|
+
}
|
|
258
|
+
// Dynamic routes per method: RouteTrie
|
|
259
|
+
this.dynamicTries = {};
|
|
260
|
+
for (const m of HTTP_METHODS) {
|
|
261
|
+
this.dynamicTries[m] = new RouteTrie();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
this.authKvBinding = 'AUTH_KV';
|
|
265
|
+
this.rateLimitKvBinding = 'RATE_LIMIT_KV';
|
|
266
|
+
this.defaults = {
|
|
267
|
+
cors: { origin: '*', methods: 'GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD' },
|
|
268
|
+
};
|
|
269
|
+
this.scheduledHandler = null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
_addRoute(method, path, options, handler) {
|
|
273
|
+
if (typeof options === 'boolean') options = { auth: options };
|
|
274
|
+
if (typeof options === 'function') {
|
|
275
|
+
handler = options;
|
|
276
|
+
options = {};
|
|
277
|
+
}
|
|
278
|
+
if (typeof handler !== 'function') {
|
|
279
|
+
throw new Error(`Handler for ${method} ${path} must be a function`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Merge with defaults once
|
|
283
|
+
const mergedOptions = { ...this.defaults, ...(options || {}) };
|
|
284
|
+
|
|
285
|
+
if (!path.includes(':')) {
|
|
286
|
+
// Static route (optimized lookup)
|
|
287
|
+
const methodMap = this.staticRoutes.get(method);
|
|
288
|
+
methodMap.set(path, { handler, options: mergedOptions });
|
|
289
|
+
} else {
|
|
290
|
+
// Dynamic route
|
|
291
|
+
this.dynamicTries[method].add(path, handler, mergedOptions);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
get(path, options, handler) { this._addRoute('GET', path, options, handler); }
|
|
296
|
+
post(path, options, handler) { this._addRoute('POST', path, options, handler); }
|
|
297
|
+
put(path, options, handler) { this._addRoute('PUT', path, options, handler); }
|
|
298
|
+
delete(path, options, handler) { this._addRoute('DELETE', path, options, handler); }
|
|
299
|
+
patch(path, options, handler) { this._addRoute('PATCH', path, options, handler); }
|
|
300
|
+
options(path, options, handler) { this._addRoute('OPTIONS', path, options, handler); }
|
|
301
|
+
head(path, options, handler) { this._addRoute('HEAD', path, options, handler); }
|
|
302
|
+
|
|
303
|
+
scheduled(handler) {
|
|
304
|
+
this.scheduledHandler = handler;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async _processAuth(ctx, flag) {
|
|
308
|
+
if (!flag) return true;
|
|
309
|
+
const token = ctx.getCookie('auth_token') ||
|
|
310
|
+
ctx.req.headers.get('Authorization')?.replace(/^Bearer\s+/i, '');
|
|
311
|
+
if (!token) return false;
|
|
312
|
+
|
|
313
|
+
const kv = ctx.env[this.authKvBinding];
|
|
314
|
+
if (!kv) return false;
|
|
315
|
+
const data = await kv.get(token);
|
|
316
|
+
if (!data) return false;
|
|
317
|
+
|
|
318
|
+
if (typeof flag === 'object') {
|
|
319
|
+
try {
|
|
320
|
+
const payload = JSON.parse(data);
|
|
321
|
+
if (flag.role && payload.role !== flag.role) return false;
|
|
322
|
+
if (flag.scopes) {
|
|
323
|
+
const userScopes = payload.scopes || [];
|
|
324
|
+
if (!flag.scopes.every(s => userScopes.includes(s))) return false;
|
|
325
|
+
}
|
|
326
|
+
} catch {}
|
|
327
|
+
}
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async _processRateLimit(ctx, flag) {
|
|
332
|
+
if (!flag) return true;
|
|
333
|
+
const options = flag === true ? {} : flag;
|
|
334
|
+
const { max = 100, window = 60 } = options;
|
|
335
|
+
const key = `rl:${ctx.req.headers.get('CF-Connecting-IP') || 'unknown'}`;
|
|
336
|
+
const kv = ctx.env[this.rateLimitKvBinding];
|
|
337
|
+
if (!kv) return true;
|
|
338
|
+
let count = await kv.get(key, 'json') || 0;
|
|
339
|
+
if (count >= max) return false;
|
|
340
|
+
count++;
|
|
341
|
+
await kv.put(key, JSON.stringify(count), { expirationTtl: window });
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
_processCors(ctx, flag) {
|
|
346
|
+
if (!flag) return;
|
|
347
|
+
const opts = typeof flag === 'object' ? flag : this.defaults.cors;
|
|
348
|
+
ctx.headers.set('Access-Control-Allow-Origin', opts.origin || '*');
|
|
349
|
+
ctx.headers.set('Access-Control-Allow-Methods', opts.methods || 'GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD');
|
|
350
|
+
ctx.headers.set('Access-Control-Allow-Headers', opts.headers || 'Content-Type, Authorization');
|
|
351
|
+
ctx.headers.set('Access-Control-Max-Age', '86400');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
_processLog(ctx, flag) {
|
|
355
|
+
if (!flag) return;
|
|
356
|
+
console.log(`${ctx.req.method} ${ctx.req.url} - ${ctx.status}`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
_processCompress(request, response) {
|
|
360
|
+
const acceptEncoding = request.headers.get('Accept-Encoding') || '';
|
|
361
|
+
let encoding = null;
|
|
362
|
+
if (acceptEncoding.includes('gzip')) encoding = 'gzip';
|
|
363
|
+
else if (acceptEncoding.includes('deflate')) encoding = 'deflate';
|
|
364
|
+
else if (acceptEncoding.includes('br')) encoding = 'br';
|
|
365
|
+
if (!encoding || !response.body) return response;
|
|
366
|
+
|
|
367
|
+
const stream = response.body.pipeThrough(new CompressionStream(encoding));
|
|
368
|
+
const newHeaders = new Headers(response.headers);
|
|
369
|
+
newHeaders.set('Content-Encoding', encoding);
|
|
370
|
+
newHeaders.set('Vary', 'Accept-Encoding');
|
|
371
|
+
return new Response(stream, { status: response.status, statusText: response.statusText, headers: newHeaders });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async _validate(ctx, flag) {
|
|
375
|
+
if (!flag) return true;
|
|
376
|
+
if (typeof flag === 'function') {
|
|
377
|
+
try {
|
|
378
|
+
const result = await flag(ctx);
|
|
379
|
+
return !!result;
|
|
380
|
+
} catch {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async _cacheGet(request) {
|
|
388
|
+
const cache = caches.default;
|
|
389
|
+
const cached = await cache.match(request);
|
|
390
|
+
if (!cached) return null;
|
|
391
|
+
return cached;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async _cachePut(request, response, flag) {
|
|
395
|
+
if (!flag) return;
|
|
396
|
+
const options = flag === true ? {} : flag;
|
|
397
|
+
const { ttl = 3600, staleWhileRevalidate = 0 } = options;
|
|
398
|
+
const cache = caches.default;
|
|
399
|
+
const responseClone = response.clone();
|
|
400
|
+
// Modify the clone's headers directly instead of creating a new Response
|
|
401
|
+
responseClone.headers.set('Cache-Control', `max-age=${ttl}${staleWhileRevalidate > 0 ? `, stale-while-revalidate=${staleWhileRevalidate}` : ''}`);
|
|
402
|
+
responseClone.headers.delete('Set-Cookie');
|
|
403
|
+
await cache.put(request, responseClone);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async fetch(request, env, executionCtx) {
|
|
407
|
+
const url = new URL(request.url);
|
|
408
|
+
const path = url.pathname;
|
|
409
|
+
const method = request.method;
|
|
410
|
+
|
|
411
|
+
// 1. Static route (fast lookup via nested map)
|
|
412
|
+
const methodMap = this.staticRoutes.get(method);
|
|
413
|
+
if (methodMap) {
|
|
414
|
+
const staticRoute = methodMap.get(path);
|
|
415
|
+
if (staticRoute) {
|
|
416
|
+
return this._handleRoute(staticRoute.handler, staticRoute.options, request, env, executionCtx, {}, url);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// 2. Dynamic route via trie
|
|
421
|
+
const trie = this.dynamicTries[method];
|
|
422
|
+
if (trie) {
|
|
423
|
+
const match = trie.match(path);
|
|
424
|
+
if (match) {
|
|
425
|
+
return this._handleRoute(match.handler, match.options, request, env, executionCtx, match.params, url);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return new Response('Not Found', { status: 404 });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async _handleRoute(handler, options, request, env, executionCtx, params, parsedUrl) {
|
|
433
|
+
const ctx = new Context(request, env, executionCtx, params, parsedUrl);
|
|
434
|
+
|
|
435
|
+
if (!(await this._validate(ctx, options.validate))) {
|
|
436
|
+
return ctx.text('Validation failed', 400);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (!(await this._processAuth(ctx, options.auth))) {
|
|
440
|
+
return ctx.text('Unauthorized', 401);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (!(await this._processRateLimit(ctx, options.rateLimit))) {
|
|
444
|
+
return ctx.text('Too Many Requests', 429);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Caching (GET only)
|
|
448
|
+
let cachedResponse = null;
|
|
449
|
+
if (options.cache && request.method === 'GET') {
|
|
450
|
+
cachedResponse = await this._cacheGet(request);
|
|
451
|
+
if (cachedResponse) {
|
|
452
|
+
this._processCors(ctx, options.cors);
|
|
453
|
+
this._processLog(ctx, options.log);
|
|
454
|
+
return cachedResponse;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
this._processCors(ctx, options.cors);
|
|
459
|
+
|
|
460
|
+
let response;
|
|
461
|
+
try {
|
|
462
|
+
const result = await handler(ctx);
|
|
463
|
+
if (result && result.__isJSX) {
|
|
464
|
+
response = ctx.html(renderToString(result));
|
|
465
|
+
} else {
|
|
466
|
+
response = result instanceof Response ? result : ctx.text('OK');
|
|
467
|
+
}
|
|
468
|
+
ctx.status = response.status;
|
|
469
|
+
} catch (err) {
|
|
470
|
+
console.error(err);
|
|
471
|
+
response = ctx.text('Internal Server Error', 500);
|
|
472
|
+
ctx.status = 500;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Cache successful GET responses
|
|
476
|
+
if (options.cache && request.method === 'GET' && response.status === 200) {
|
|
477
|
+
ctx.executionCtx.waitUntil(this._cachePut(request, response.clone(), options.cache));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (options.compress) {
|
|
481
|
+
response = this._processCompress(request, response);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
this._processLog(ctx, options.log);
|
|
485
|
+
|
|
486
|
+
return response;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
class Context{constructor(r,e,x,p={},u=null){this.req=r;this.env=e;this.executionCtx=x;this.params=p;this.status=200;this.headers=new Headers;this._rawCookie=r.headers.get("Cookie")||"";this._cookies=null;this.url=u||new URL(r.url)}_ensureCookies(){if(this._cookies===null){let c={};if(this._rawCookie){for(const p of this._rawCookie.split(";")){const t=p.trim();if(!t)continue;const i=t.indexOf("=");if(i>0){const n=decodeURIComponent(t.slice(0,i)),v=decodeURIComponent(t.slice(i+1));c[n]=v}}}this._cookies=c}return this._cookies}getCookie(n){return this._ensureCookies()[n]??null}get query(){return this.url.searchParams}setCookie(n,v,o={}){let c=`${encodeURIComponent(n)}=${encodeURIComponent(v)}`;if(o.path)c+=`; Path=${o.path}`;if(o.domain)c+=`; Domain=${o.domain}`;if(o.maxAge!==undefined)c+=`; Max-Age=${o.maxAge}`;if(o.expires)c+=`; Expires=${o.expires.toUTCString()}`;if(o.secure)c+=`; Secure`;if(o.httpOnly)c+=`; HttpOnly`;if(o.sameSite)c+=`; SameSite=${o.sameSite}`;this.headers.append("Set-Cookie",c)}deleteCookie(n,o={}){this.setCookie(n,"",{...o,maxAge:0,expires:new Date(0)})}text(d,s=this.status,h={}){const r=this._buildHeaders(h);r.set("Content-Type","text/plain");return new Response(d,{status:s,headers:r})}json(d,s=this.status,h={}){const r=this._buildHeaders(h);r.set("Content-Type","application/json");return new Response(JSON.stringify(d),{status:s,headers:r})}html(d,s=this.status,h={}){const r=this._buildHeaders(h);r.set("Content-Type","text/html");return new Response(d,{status:s,headers:r})}_buildHeaders(h){if(Object.keys(h).length===0)return this.headers;const r=new Headers(this.headers);for(const k in h)if(Object.prototype.hasOwnProperty.call(h,k))r.set(k,h[k]);return r}}
|
|
2
|
+
const Fragment=Symbol("Fragment");
|
|
3
|
+
function jsx(t,p,...c){const n=p||{},f=c.flat(1/0);return{type:t,props:n,children:f,__isJSX:!0}}
|
|
4
|
+
function renderToString(n){if(n==null||typeof n=="boolean")return"";if(typeof n=="string"||typeof n=="number")return escapeHtml(String(n));if(Array.isArray(n)){const a=[];for(let i=0;i<n.length;i++)a.push(renderToString(n[i]));return a.join("")}if(!n.__isJSX)return escapeHtml(String(n));const{type:t,props:p,children:c}=n;if(t===Fragment){const a=[];for(let i=0;i<c.length;i++)a.push(renderToString(c[i]));return a.join("")}if(typeof t=="symbol")return"";if(typeof t=="function")return renderToString(t({...p,children:c}));const ap=[];for(const k in p){if(k==="children")continue;const v=p[k];if(v==null||v===!1)continue;if(k==="className")ap.push(` class="${escapeHtml(v)}"`);else if(k==="htmlFor")ap.push(` for="${escapeHtml(v)}"`);else if(k.startsWith("on")&&typeof v=="function")continue;else if(k==="style"&&typeof v=="object"){const s=Object.entries(v).map(([a,b])=>`${camelToKebab(a)}:${b}`).join(";");ap.push(` style="${escapeHtml(s)}"`)}else if(v===!0)ap.push(` ${k}`);else ap.push(` ${k}="${escapeHtml(String(v))}"`)}const at=ap.join(""),cp=[];for(let i=0;i<c.length;i++)cp.push(renderToString(c[i]));return `<${t}${at}>${cp.join("")}</${t}>`}
|
|
5
|
+
const HTML_ESCAPE_MAP={"&":"&","<":"<",">":">",'"':""","'":"'"};
|
|
6
|
+
function escapeHtml(s){return s.replace(/[&<>"']/g,c=>HTML_ESCAPE_MAP[c])}
|
|
7
|
+
function camelToKebab(s){return s.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}
|
|
8
|
+
class TrieNode{constructor(){this.children=new Map;this.paramChild=null;this.paramName=null;this.handler=null}}
|
|
9
|
+
class RouteTrie{constructor(){this.root=new TrieNode}add(p,h,o){const s=p.split("/").filter(Boolean);let n=this.root;for(const seg of s){if(seg.startsWith(":")){if(!n.paramChild){n.paramChild=new TrieNode;n.paramName=seg.slice(1)}n=n.paramChild}else{if(!n.children.has(seg))n.children.set(seg,new TrieNode);n=n.children.get(seg)}}n.handler={handler:h,options:o}}match(p){const s=p.split("/").filter(Boolean);let n=this.root;const pr={};for(const seg of s){if(n.children.has(seg))n=n.children.get(seg);else if(n.paramChild){pr[n.paramName]=seg;n=n.paramChild}else return null}return n.handler?{handler:n.handler.handler,options:n.handler.options,params:pr}:null}}
|
|
10
|
+
const HTTP_METHODS=["GET","POST","PUT","DELETE","PATCH","OPTIONS","HEAD"];
|
|
11
|
+
class Edge{constructor(){this.staticRoutes=new Map;for(const m of HTTP_METHODS)this.staticRoutes.set(m,new Map);this.dynamicTries={};for(const m of HTTP_METHODS)this.dynamicTries[m]=new RouteTrie;this.authKvBinding="AUTH_KV";this.rateLimitKvBinding="RATE_LIMIT_KV";this.defaults={cors:{origin:"*",methods:"GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD"}};this.scheduledHandler=null}_addRoute(m,p,o,h){if(typeof o=="boolean")o={auth:o};if(typeof o=="function"){h=o;o={}}if(typeof h!="function")throw new Error(`Handler for ${m} ${p} must be a function`);const mo={...this.defaults,...(o||{})};if(!p.includes(":"))this.staticRoutes.get(m).set(p,{handler:h,options:mo});else this.dynamicTries[m].add(p,h,mo)}get(p,o,h){this._addRoute("GET",p,o,h)}post(p,o,h){this._addRoute("POST",p,o,h)}put(p,o,h){this._addRoute("PUT",p,o,h)}delete(p,o,h){this._addRoute("DELETE",p,o,h)}patch(p,o,h){this._addRoute("PATCH",p,o,h)}options(p,o,h){this._addRoute("OPTIONS",p,o,h)}head(p,o,h){this._addRoute("HEAD",p,o,h)}scheduled(h){this.scheduledHandler=h}async _processAuth(c,f){if(!f)return!0;const t=c.getCookie("auth_token")||c.req.headers.get("Authorization")?.replace(/^Bearer\s+/i,"");if(!t)return!1;const kv=c.env[this.authKvBinding];if(!kv)return!1;const d=await kv.get(t);if(!d)return!1;if(typeof f=="object"){try{const pl=JSON.parse(d);if(f.role&&pl.role!==f.role)return!1;if(f.scopes){const us=pl.scopes||[];if(!f.scopes.every(s=>us.includes(s)))return!1}}catch{}}return!0}async _processRateLimit(c,f){if(!f)return!0;const o=f===!0?{}:f,{max=100,window=60}=o;const key=`rl:${c.req.headers.get("CF-Connecting-IP")||"unknown"}`;const kv=c.env[this.rateLimitKvBinding];if(!kv)return!0;let cnt=await kv.get(key,"json")||0;if(cnt>=max)return!1;cnt++;await kv.put(key,JSON.stringify(cnt),{expirationTtl:window});return!0}_processCors(c,f){if(!f)return;const o=typeof f=="object"?f:this.defaults.cors;c.headers.set("Access-Control-Allow-Origin",o.origin||"*");c.headers.set("Access-Control-Allow-Methods",o.methods||"GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD");c.headers.set("Access-Control-Allow-Headers",o.headers||"Content-Type, Authorization");c.headers.set("Access-Control-Max-Age","86400")}_processLog(c,f){if(f)console.log(`${c.req.method} ${c.req.url} - ${c.status}`)}_processCompress(req,res){const ae=req.headers.get("Accept-Encoding")||"";let enc=null;if(ae.includes("gzip"))enc="gzip";else if(ae.includes("deflate"))enc="deflate";else if(ae.includes("br"))enc="br";if(!enc||!res.body)return res;const st=res.body.pipeThrough(new CompressionStream(enc));const nh=new Headers(res.headers);nh.set("Content-Encoding",enc);nh.set("Vary","Accept-Encoding");return new Response(st,{status:res.status,statusText:res.statusText,headers:nh})}async _validate(c,f){if(!f)return!0;if(typeof f=="function"){try{return!!(await f(c))}catch{return!1}}return!0}async _cacheGet(req){const cache=caches.default;return await cache.match(req)||null}async _cachePut(req,res,f){if(!f)return;const o=f===!0?{}:f,{ttl=3600,staleWhileRevalidate=0}=o;const cache=caches.default;const clone=res.clone();clone.headers.set("Cache-Control",`max-age=${ttl}${staleWhileRevalidate>0?`, stale-while-revalidate=${staleWhileRevalidate}`:""}`);clone.headers.delete("Set-Cookie");await cache.put(req,clone)}async fetch(req,env,ctx){const url=new URL(req.url),path=url.pathname,method=req.method;const mm=this.staticRoutes.get(method);if(mm){const sr=mm.get(path);if(sr)return this._handleRoute(sr.handler,sr.options,req,env,ctx,{},url)}const trie=this.dynamicTries[method];if(trie){const m=trie.match(path);if(m)return this._handleRoute(m.handler,m.options,req,env,ctx,m.params,url)}return new Response("Not Found",{status:404})}async _handleRoute(h,o,req,env,ctx,p,url){const c=new Context(req,env,ctx,p,url);if(!(await this._validate(c,o.validate)))return c.text("Validation failed",400);if(!(await this._processAuth(c,o.auth)))return c.text("Unauthorized",401);if(!(await this._processRateLimit(c,o.rateLimit)))return c.text("Too Many Requests",429);let cached=null;if(o.cache&&req.method==="GET"){cached=await this._cacheGet(req);if(cached){this._processCors(c,o.cors);this._processLog(c,o.log);return cached}}this._processCors(c,o.cors);let res;try{const r=await h(c);if(r&&r.__isJSX)res=c.html(renderToString(r));else res=r instanceof Response?r:c.text("OK");c.status=res.status}catch(e){console.error(e);res=c.text("Internal Server Error",500);c.status=500}if(o.cache&&req.method==="GET"&&res.status===200)c.executionCtx.waitUntil(this._cachePut(req,res.clone(),o.cache));if(o.compress)res=this._processCompress(req,res);this._processLog(c,o.log);return res}}
|
|
12
|
+
export{Fragment,jsx,renderToString,Edge};
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lengkapp/edge",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Edge framework used by Lengkapp",
|
|
5
|
+
"main": "edge-server.js",
|
|
6
|
+
"browser": "edge-client.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"browser": "./edge-client.js",
|
|
10
|
+
"node": "./edge-server.js",
|
|
11
|
+
"default": "./edge-server.js"
|
|
12
|
+
},
|
|
13
|
+
"./client": "./edge-client.js",
|
|
14
|
+
"./server": "./edge-server.js",
|
|
15
|
+
"./client.min": "./edge-client.min.js",
|
|
16
|
+
"./server.min": "./edge-server.min.js",
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"edge-client.js",
|
|
21
|
+
"edge-client.min.js",
|
|
22
|
+
"edge-server.js",
|
|
23
|
+
"edge-server.min.js"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "echo \"No tests yet\""
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"keywords": ["cloudflare workers","minimal server library","minimal client library"],
|
|
32
|
+
"author": "Yasir Haris",
|
|
33
|
+
"license": "MIT"
|
|
34
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
sample usage of edge.js
|
|
2
|
+
```js
|
|
3
|
+
import { Edge } from './edge.js';
|
|
4
|
+
import { HomePage, AboutPage } from './Page.jsx'; // bundler resolves .jsx
|
|
5
|
+
|
|
6
|
+
const app = new Edge();
|
|
7
|
+
|
|
8
|
+
// Basic text
|
|
9
|
+
app.get('/', (ctx) => {
|
|
10
|
+
return ctx.text('Hello from Edge!');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
app.get('/home', (ctx) => {
|
|
14
|
+
// The component returns a JSX element; Edge automatically renders it to HTML
|
|
15
|
+
return ctx.html(renderToString(HomePage()));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
app.get('/about', (ctx) => {
|
|
19
|
+
return ctx.html(renderToString(AboutPage()));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// JSON with route params
|
|
23
|
+
app.get('/users/:id', (ctx) => {
|
|
24
|
+
return ctx.json({ userId: ctx.params.id });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// POST JSON
|
|
28
|
+
app.post('/users', async (ctx) => {
|
|
29
|
+
const body = await ctx.req.json();
|
|
30
|
+
return ctx.json({ created: true, user: body }, 201);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Auth (requires AUTH_KV binding)
|
|
34
|
+
app.get('/protected', { auth: true }, (ctx) => {
|
|
35
|
+
return ctx.text('Authenticated area');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Role‑based auth
|
|
39
|
+
app.get('/admin', { auth: { role: 'admin' } }, (ctx) => {
|
|
40
|
+
return ctx.text('Admin only');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Rate limiting
|
|
44
|
+
app.get('/limited', { rateLimit: { max: 5, window: 60 } }, (ctx) => {
|
|
45
|
+
return ctx.text('Rate limited endpoint');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Caching (GET only, 60s TTL)
|
|
49
|
+
app.get('/cached', { cache: { ttl: 60 } }, (ctx) => {
|
|
50
|
+
return ctx.text('Cached response');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// CORS
|
|
54
|
+
app.get('/cors', { cors: true }, (ctx) => {
|
|
55
|
+
return ctx.json({ message: 'CORS enabled' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Logging
|
|
59
|
+
app.get('/log', { log: true }, (ctx) => {
|
|
60
|
+
return ctx.text('This request is logged');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Compression
|
|
64
|
+
app.get('/compress', { compress: true }, (ctx) => {
|
|
65
|
+
return ctx.text('x'.repeat(10000));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Cookies
|
|
69
|
+
app.get('/set-cookie', (ctx) => {
|
|
70
|
+
ctx.setCookie('session', 'abc123', { httpOnly: true, path: '/' });
|
|
71
|
+
return ctx.text('Cookie set');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
app.get('/get-cookie', (ctx) => {
|
|
75
|
+
const session = ctx.getCookie('session');
|
|
76
|
+
return ctx.text(`Cookie: ${session}`);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Custom validation
|
|
80
|
+
app.post('/submit', {
|
|
81
|
+
validate: (ctx) => ctx.req.headers.get('Authorization') === 'Bearer secret-token'
|
|
82
|
+
}, (ctx) => ctx.text('Validated'));
|
|
83
|
+
|
|
84
|
+
// Scheduled handler (cron)
|
|
85
|
+
app.scheduled(async (event, env, ctx) => {
|
|
86
|
+
console.log('Cron job:', event.cron);
|
|
87
|
+
// Do periodic work here
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
export default {
|
|
93
|
+
fetch: (request, env, ctx) => app.fetch(request, env, ctx),
|
|
94
|
+
scheduled: (event, env, ctx) => {
|
|
95
|
+
if (app.scheduledHandler) return app.scheduledHandler(event, env, ctx);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
```rust
|
|
102
|
+
name = "my-edge-app"
|
|
103
|
+
main = "worker.js"
|
|
104
|
+
compatibility_date = "2024-09-01"
|
|
105
|
+
|
|
106
|
+
# KV namespaces (if using auth/rate limit)
|
|
107
|
+
[[kv_namespaces]]
|
|
108
|
+
binding = "AUTH_KV"
|
|
109
|
+
id = "your-auth-kv-id"
|
|
110
|
+
|
|
111
|
+
[[kv_namespaces]]
|
|
112
|
+
binding = "RATE_LIMIT_KV"
|
|
113
|
+
id = "your-ratelimit-kv-id"
|
|
114
|
+
|
|
115
|
+
# Cron triggers
|
|
116
|
+
[triggers]
|
|
117
|
+
crons = ["*/5 * * * *"]
|
|
118
|
+
```
|