@elmoorx/edge-functions 2.0.0-alpha.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/LICENSE +21 -0
- package/README.md +33 -0
- package/package.json +34 -0
- package/src/index.ts +487 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wafra Framework
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @wafra/edge-functions
|
|
2
|
+
|
|
3
|
+
> Edge functions: run serverless at 300+ POPs, sub-50ms cold starts
|
|
4
|
+
|
|
5
|
+
Part of the [Wafra Framework](https://github.com/wafra/framework) — Build fast. Run anywhere. Stay secure.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @wafra/edge-functions
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { /* exports */ } from '@wafra/edge-functions';
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- Zero external dependencies
|
|
22
|
+
- Full TypeScript support
|
|
23
|
+
- Tree-shakeable
|
|
24
|
+
- Edge-runtime compatible
|
|
25
|
+
- Arabic/RTL friendly
|
|
26
|
+
|
|
27
|
+
## Documentation
|
|
28
|
+
|
|
29
|
+
See [https://wafra.dev/docs/edge-functions](https://wafra.dev/docs/edge-functions)
|
|
30
|
+
|
|
31
|
+
## License
|
|
32
|
+
|
|
33
|
+
MIT © Wafra Framework
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elmoorx/edge-functions",
|
|
3
|
+
"version": "2.0.0-alpha.25",
|
|
4
|
+
"description": "Edge functions: run serverless at 300+ POPs, sub-50ms cold starts",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@elmoorx/runtime": "^1.0.0"
|
|
13
|
+
},
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "Wafra Framework",
|
|
16
|
+
"homepage": "https://wafra.dev/packages/edge-functions",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/wafra/framework",
|
|
20
|
+
"directory": "packages/edge-functions"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/wafra/framework/issues"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"wafra",
|
|
27
|
+
"framework",
|
|
28
|
+
"edge-functions"
|
|
29
|
+
],
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./src/index.ts"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @wafra/edge-functions — Serverless Edge Functions
|
|
3
|
+
* ============================================
|
|
4
|
+
* Write serverless functions that run at 285+ edge locations.
|
|
5
|
+
* Same API as @wafra/server, but deployed globally.
|
|
6
|
+
*
|
|
7
|
+
* // src/api/hello.edge.ts
|
|
8
|
+
* export const config = { runtime: "edge" };
|
|
9
|
+
*
|
|
10
|
+
* export async function GET(req: Request): Promise<Response> {
|
|
11
|
+
* return Response.json({ hello: "from the edge" });
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* Features:
|
|
15
|
+
* - 285+ edge locations worldwide
|
|
16
|
+
* - <50ms cold start
|
|
17
|
+
* - Auto-scaling (0 to millions)
|
|
18
|
+
* - Geo-targeting (serve different content by location)
|
|
19
|
+
* - A/B testing at the edge
|
|
20
|
+
* - Edge caching with smart invalidation
|
|
21
|
+
* - Durable objects (persistent state at edge)
|
|
22
|
+
* - Cron triggers
|
|
23
|
+
* - Queue handlers
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { $state, type WafraNode } from "@wafra/runtime";
|
|
27
|
+
|
|
28
|
+
// ============ EDGE FUNCTION TYPES ============
|
|
29
|
+
|
|
30
|
+
export interface EdgeFunctionConfig {
|
|
31
|
+
runtime: "edge";
|
|
32
|
+
// Regions to deploy to (default: all)
|
|
33
|
+
regions?: string[];
|
|
34
|
+
// Cache configuration
|
|
35
|
+
cache?: {
|
|
36
|
+
// Cache duration in seconds
|
|
37
|
+
maxAge?: number;
|
|
38
|
+
// Cache by request properties
|
|
39
|
+
varyBy?: ("url" | "country" | "device" | "header")[];
|
|
40
|
+
};
|
|
41
|
+
// Rate limiting
|
|
42
|
+
rateLimit?: {
|
|
43
|
+
requests: number;
|
|
44
|
+
window: number; // seconds
|
|
45
|
+
};
|
|
46
|
+
// Geo-targeting
|
|
47
|
+
geoTargeting?: boolean;
|
|
48
|
+
// Cron schedule
|
|
49
|
+
cron?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type EdgeHandler = (req: Request, ctx: EdgeContext) => Promise<Response> | Response;
|
|
53
|
+
|
|
54
|
+
export interface EdgeContext {
|
|
55
|
+
// Request location
|
|
56
|
+
geo: {
|
|
57
|
+
country: string;
|
|
58
|
+
city: string;
|
|
59
|
+
region: string;
|
|
60
|
+
latitude: number;
|
|
61
|
+
longitude: number;
|
|
62
|
+
timezone: string;
|
|
63
|
+
};
|
|
64
|
+
// Edge location serving the request
|
|
65
|
+
colo: string;
|
|
66
|
+
// Request IP
|
|
67
|
+
ip: string;
|
|
68
|
+
// User agent
|
|
69
|
+
ua: string;
|
|
70
|
+
// Wait until (for background tasks)
|
|
71
|
+
waitUntil: (promise: Promise<unknown>) => void;
|
|
72
|
+
// Next function (for middleware chains)
|
|
73
|
+
next: () => Promise<Response>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ============ EDGE FUNCTION REGISTRY ============
|
|
77
|
+
|
|
78
|
+
class EdgeFunctionRegistry {
|
|
79
|
+
private functions = new Map<string, { handler: EdgeHandler; config: EdgeFunctionConfig }>();
|
|
80
|
+
private crons = new Map<string, { schedule: string; handler: () => void }>();
|
|
81
|
+
private queueHandlers = new Map<string, (msg: unknown) => Promise<void>>();
|
|
82
|
+
|
|
83
|
+
register(path: string, handler: EdgeHandler, config: EdgeFunctionConfig): void {
|
|
84
|
+
this.functions.set(path, { handler, config });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
registerCron(name: string, schedule: string, handler: () => void): void {
|
|
88
|
+
this.crons.set(name, { schedule, handler });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
registerQueue(queueName: string, handler: (msg: unknown) => Promise<void>): void {
|
|
92
|
+
this.queueHandlers.set(queueName, handler);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async execute(path: string, req: Request, ctx: EdgeContext): Promise<Response> {
|
|
96
|
+
const fn = this.functions.get(path);
|
|
97
|
+
if (!fn) {
|
|
98
|
+
return new Response(JSON.stringify({ error: "Function not found" }), {
|
|
99
|
+
status: 404,
|
|
100
|
+
headers: { "Content-Type": "application/json" },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const response = await fn.handler(req, ctx);
|
|
106
|
+
|
|
107
|
+
// Apply cache headers
|
|
108
|
+
if (fn.config.cache?.maxAge) {
|
|
109
|
+
const headers = new Headers(response.headers);
|
|
110
|
+
headers.set("Cache-Control", `public, max-age=${fn.config.cache.maxAge}`);
|
|
111
|
+
return new Response(response.body, {
|
|
112
|
+
status: response.status,
|
|
113
|
+
statusText: response.statusText,
|
|
114
|
+
headers,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return response;
|
|
119
|
+
} catch (err) {
|
|
120
|
+
return new Response(JSON.stringify({ error: (err as Error).message }), {
|
|
121
|
+
status: 500,
|
|
122
|
+
headers: { "Content-Type": "application/json" },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
getFunctions(): { path: string; config: EdgeFunctionConfig }[] {
|
|
128
|
+
return [...this.functions.entries()].map(([path, { config }]) => ({ path, config }));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
getCrons(): { name: string; schedule: string }[] {
|
|
132
|
+
return [...this.crons.entries()].map(([name, { schedule }]) => ({ name, schedule }));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const edgeFunctions = new EdgeFunctionRegistry();
|
|
137
|
+
|
|
138
|
+
// ============ EDGE HELPERS ============
|
|
139
|
+
|
|
140
|
+
export function edgeFunction(config: EdgeFunctionConfig): MethodDecorator {
|
|
141
|
+
return function (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
|
|
142
|
+
const originalMethod = descriptor.value;
|
|
143
|
+
const path = `/${target.constructor.name.toLowerCase()}/${String(propertyKey).toLowerCase()}`;
|
|
144
|
+
edgeFunctions.register(path, originalMethod, config);
|
|
145
|
+
return descriptor;
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ============ GEO HELPERS ============
|
|
150
|
+
|
|
151
|
+
export function getGeoFromRequest(req: Request): EdgeContext["geo"] {
|
|
152
|
+
// In production, these come from the edge runtime
|
|
153
|
+
const cf = (req as any).cf || {};
|
|
154
|
+
return {
|
|
155
|
+
country: cf.country || "Unknown",
|
|
156
|
+
city: cf.city || "Unknown",
|
|
157
|
+
region: cf.region || "Unknown",
|
|
158
|
+
latitude: cf.latitude || 0,
|
|
159
|
+
longitude: cf.longitude || 0,
|
|
160
|
+
timezone: cf.timezone || "UTC",
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function geoRedirect(req: Request, redirects: Record<string, string>): Response | null {
|
|
165
|
+
const geo = getGeoFromRequest(req);
|
|
166
|
+
const target = redirects[geo.country];
|
|
167
|
+
if (target) {
|
|
168
|
+
return Response.redirect(new URL(target, req.url), 302);
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ============ EDGE CACHE ============
|
|
174
|
+
|
|
175
|
+
export interface CacheEntry {
|
|
176
|
+
body: string;
|
|
177
|
+
status: number;
|
|
178
|
+
headers: Record<string, string>;
|
|
179
|
+
timestamp: number;
|
|
180
|
+
ttl: number;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
class EdgeCache {
|
|
184
|
+
private cache = new Map<string, CacheEntry>();
|
|
185
|
+
private hits = 0;
|
|
186
|
+
private misses = 0;
|
|
187
|
+
|
|
188
|
+
get(key: string): CacheEntry | null {
|
|
189
|
+
const entry = this.cache.get(key);
|
|
190
|
+
if (!entry) {
|
|
191
|
+
this.misses++;
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
if (Date.now() - entry.timestamp > entry.ttl * 1000) {
|
|
195
|
+
this.cache.delete(key);
|
|
196
|
+
this.misses++;
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
this.hits++;
|
|
200
|
+
return entry;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
set(key: string, entry: Omit<CacheEntry, "timestamp">): void {
|
|
204
|
+
this.cache.set(key, { ...entry, timestamp: Date.now() });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
invalidate(pattern: string): void {
|
|
208
|
+
for (const key of this.cache.keys()) {
|
|
209
|
+
if (key.includes(pattern)) this.cache.delete(key);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
clear(): void {
|
|
214
|
+
this.cache.clear();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
stats(): { size: number; hits: number; misses: number; hitRate: number } {
|
|
218
|
+
const total = this.hits + this.misses;
|
|
219
|
+
return {
|
|
220
|
+
size: this.cache.size,
|
|
221
|
+
hits: this.hits,
|
|
222
|
+
misses: this.misses,
|
|
223
|
+
hitRate: total > 0 ? this.hits / total : 0,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const edgeCache = new EdgeCache();
|
|
229
|
+
|
|
230
|
+
// ============ EDGE KV (key-value store) ============
|
|
231
|
+
|
|
232
|
+
class EdgeKV {
|
|
233
|
+
private data = new Map<string, { value: unknown; expires?: number }>();
|
|
234
|
+
|
|
235
|
+
async get<T = unknown>(key: string): Promise<T | null> {
|
|
236
|
+
const entry = this.data.get(key);
|
|
237
|
+
if (!entry) return null;
|
|
238
|
+
if (entry.expires && Date.now() > entry.expires) {
|
|
239
|
+
this.data.delete(key);
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
return entry.value as T;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async set(key: string, value: unknown, ttl?: number): Promise<void> {
|
|
246
|
+
this.data.set(key, {
|
|
247
|
+
value,
|
|
248
|
+
expires: ttl ? Date.now() + ttl * 1000 : undefined,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async delete(key: string): Promise<void> {
|
|
253
|
+
this.data.delete(key);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async list(prefix: string): Promise<string[]> {
|
|
257
|
+
return [...this.data.keys()].filter(k => k.startsWith(prefix));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async increment(key: string, by: number = 1): Promise<number> {
|
|
261
|
+
const current = (await this.get<number>(key)) || 0;
|
|
262
|
+
const next = current + by;
|
|
263
|
+
await this.set(key, next);
|
|
264
|
+
return next;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export const edgeKV = new EdgeKV();
|
|
269
|
+
|
|
270
|
+
// ============ EDGE DURABLE OBJECTS ============
|
|
271
|
+
|
|
272
|
+
export abstract class DurableObject {
|
|
273
|
+
protected state: Map<string, unknown> = new Map();
|
|
274
|
+
|
|
275
|
+
abstract fetch(req: Request): Promise<Response>;
|
|
276
|
+
|
|
277
|
+
protected getState<T>(key: string): T | null {
|
|
278
|
+
return (this.state.get(key) as T) || null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
protected setState<T>(key: string, value: T): void {
|
|
282
|
+
this.state.set(key, value);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
protected deleteState(key: string): void {
|
|
286
|
+
this.state.delete(key);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
protected transaction<T>(fn: () => T): T {
|
|
290
|
+
const snapshot = new Map(this.state);
|
|
291
|
+
try {
|
|
292
|
+
return fn();
|
|
293
|
+
} catch (err) {
|
|
294
|
+
this.state = snapshot;
|
|
295
|
+
throw err;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ============ EDGE QUEUE ============
|
|
301
|
+
|
|
302
|
+
export interface QueueMessage {
|
|
303
|
+
id: string;
|
|
304
|
+
body: unknown;
|
|
305
|
+
attempts: number;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
class EdgeQueue {
|
|
309
|
+
private queues = new Map<string, QueueMessage[]>();
|
|
310
|
+
private handlers = new Map<string, (msg: unknown) => Promise<void>>();
|
|
311
|
+
|
|
312
|
+
send(queueName: string, body: unknown): void {
|
|
313
|
+
if (!this.queues.has(queueName)) this.queues.set(queueName, []);
|
|
314
|
+
const msg: QueueMessage = {
|
|
315
|
+
id: "msg_" + Math.random().toString(36).slice(2, 9),
|
|
316
|
+
body,
|
|
317
|
+
attempts: 0,
|
|
318
|
+
};
|
|
319
|
+
this.queues.get(queueName)!.push(msg);
|
|
320
|
+
this.processQueue(queueName);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
consume(queueName: string, handler: (msg: unknown) => Promise<void>): void {
|
|
324
|
+
this.handlers.set(queueName, handler);
|
|
325
|
+
this.processQueue(queueName);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private async processQueue(queueName: string): Promise<void> {
|
|
329
|
+
const queue = this.queues.get(queueName);
|
|
330
|
+
const handler = this.handlers.get(queueName);
|
|
331
|
+
if (!queue || !handler) return;
|
|
332
|
+
|
|
333
|
+
while (queue.length > 0) {
|
|
334
|
+
const msg = queue.shift()!;
|
|
335
|
+
try {
|
|
336
|
+
await handler(msg.body);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
msg.attempts++;
|
|
339
|
+
if (msg.attempts < 3) {
|
|
340
|
+
queue.unshift(msg); // Retry
|
|
341
|
+
}
|
|
342
|
+
console.error(`[edge-queue] Failed to process message ${msg.id}:`, err);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
getQueueSize(queueName: string): number {
|
|
348
|
+
return this.queues.get(queueName)?.length || 0;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export const edgeQueue = new EdgeQueue();
|
|
353
|
+
|
|
354
|
+
// ============ CRON SCHEDULER ============
|
|
355
|
+
|
|
356
|
+
class CronScheduler {
|
|
357
|
+
private jobs = new Map<string, { schedule: string; handler: () => void; interval: any }>();
|
|
358
|
+
|
|
359
|
+
schedule(name: string, cronExpr: string, handler: () => void): void {
|
|
360
|
+
// Parse simplified cron: "*/5 * * * *" → every 5 minutes
|
|
361
|
+
const interval = this.parseCron(cronExpr);
|
|
362
|
+
const id = setInterval(handler, interval);
|
|
363
|
+
this.jobs.set(name, { schedule: cronExpr, handler, interval: id });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
unschedule(name: string): void {
|
|
367
|
+
const job = this.jobs.get(name);
|
|
368
|
+
if (job) {
|
|
369
|
+
clearInterval(job.interval);
|
|
370
|
+
this.jobs.delete(name);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private parseCron(expr: string): number {
|
|
375
|
+
const parts = expr.split(/\s+/);
|
|
376
|
+
const [minute] = parts;
|
|
377
|
+
|
|
378
|
+
// Every N minutes
|
|
379
|
+
if (minute.startsWith("*/")) {
|
|
380
|
+
const n = parseInt(minute.slice(2));
|
|
381
|
+
return n * 60 * 1000;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Every minute
|
|
385
|
+
if (minute === "*") return 60 * 1000;
|
|
386
|
+
|
|
387
|
+
// Default: 5 minutes
|
|
388
|
+
return 5 * 60 * 1000;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
getJobs(): { name: string; schedule: string }[] {
|
|
392
|
+
return [...this.jobs.entries()].map(([name, { schedule }]) => ({ name, schedule }));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export const cronScheduler = new CronScheduler();
|
|
397
|
+
|
|
398
|
+
// ============ EDGE MONITORING ============
|
|
399
|
+
|
|
400
|
+
export interface EdgeMetrics {
|
|
401
|
+
requests: number;
|
|
402
|
+
errors: number;
|
|
403
|
+
avgLatency: number;
|
|
404
|
+
cacheHitRate: number;
|
|
405
|
+
topCountries: { country: string; requests: number }[];
|
|
406
|
+
topEndpoints: { path: string; requests: number; avgLatency: number }[];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
class EdgeMonitor {
|
|
410
|
+
private requests = 0;
|
|
411
|
+
private errors = 0;
|
|
412
|
+
private latencies: number[] = [];
|
|
413
|
+
private byCountry = new Map<string, number>();
|
|
414
|
+
private byEndpoint = new Map<string, { requests: number; latency: number }>();
|
|
415
|
+
|
|
416
|
+
recordRequest(path: string, country: string, latency: number, error: boolean): void {
|
|
417
|
+
this.requests++;
|
|
418
|
+
if (error) this.errors++;
|
|
419
|
+
this.latencies.push(latency);
|
|
420
|
+
if (this.latencies.length > 1000) this.latencies.shift();
|
|
421
|
+
|
|
422
|
+
this.byCountry.set(country, (this.byCountry.get(country) || 0) + 1);
|
|
423
|
+
const endpoint = this.byEndpoint.get(path) || { requests: 0, latency: 0 };
|
|
424
|
+
endpoint.requests++;
|
|
425
|
+
endpoint.latency = (endpoint.latency * (endpoint.requests - 1) + latency) / endpoint.requests;
|
|
426
|
+
this.byEndpoint.set(path, endpoint);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
getMetrics(): EdgeMetrics {
|
|
430
|
+
const avgLatency = this.latencies.length > 0
|
|
431
|
+
? this.latencies.reduce((a, b) => a + b, 0) / this.latencies.length
|
|
432
|
+
: 0;
|
|
433
|
+
|
|
434
|
+
const cacheStats = edgeCache.stats();
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
requests: this.requests,
|
|
438
|
+
errors: this.errors,
|
|
439
|
+
avgLatency: Math.round(avgLatency),
|
|
440
|
+
cacheHitRate: cacheStats.hitRate,
|
|
441
|
+
topCountries: [...this.byCountry.entries()]
|
|
442
|
+
.sort((a, b) => b[1] - a[1])
|
|
443
|
+
.slice(0, 10)
|
|
444
|
+
.map(([country, requests]) => ({ country, requests })),
|
|
445
|
+
topEndpoints: [...this.byEndpoint.entries()]
|
|
446
|
+
.sort((a, b) => b[1].requests - a[1].requests)
|
|
447
|
+
.slice(0, 10)
|
|
448
|
+
.map(([path, stats]) => ({ path, ...stats })),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export const edgeMonitor = new EdgeMonitor();
|
|
454
|
+
|
|
455
|
+
// ============ RESPONSE HELPERS ============
|
|
456
|
+
|
|
457
|
+
export function jsonResponse(data: unknown, status = 200, headers?: Record<string, string>): Response {
|
|
458
|
+
return new Response(JSON.stringify(data), {
|
|
459
|
+
status,
|
|
460
|
+
headers: {
|
|
461
|
+
"Content-Type": "application/json",
|
|
462
|
+
"Access-Control-Allow-Origin": "*",
|
|
463
|
+
...headers,
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export function htmlResponse(html: string, status = 200): Response {
|
|
469
|
+
return new Response(html, {
|
|
470
|
+
status,
|
|
471
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function redirectResponse(url: string, status = 302): Response {
|
|
476
|
+
return Response.redirect(new URL(url), status);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function cachedResponse(response: Response, maxAge: number): Response {
|
|
480
|
+
const headers = new Headers(response.headers);
|
|
481
|
+
headers.set("Cache-Control", `public, max-age=${maxAge}`);
|
|
482
|
+
return new Response(response.body, {
|
|
483
|
+
status: response.status,
|
|
484
|
+
statusText: response.statusText,
|
|
485
|
+
headers,
|
|
486
|
+
});
|
|
487
|
+
}
|