@umituz/web-cloudflare 1.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/LICENSE +21 -0
- package/README.md +621 -0
- package/package.json +87 -0
- package/src/config/patterns.ts +469 -0
- package/src/config/types.ts +648 -0
- package/src/domain/entities/analytics.entity.ts +47 -0
- package/src/domain/entities/d1.entity.ts +37 -0
- package/src/domain/entities/image.entity.ts +48 -0
- package/src/domain/entities/index.ts +11 -0
- package/src/domain/entities/kv.entity.ts +34 -0
- package/src/domain/entities/r2.entity.ts +55 -0
- package/src/domain/entities/worker.entity.ts +35 -0
- package/src/domain/index.ts +7 -0
- package/src/domain/interfaces/index.ts +6 -0
- package/src/domain/interfaces/services.interface.ts +82 -0
- package/src/index.ts +53 -0
- package/src/infrastructure/constants/index.ts +13 -0
- package/src/infrastructure/domain/ai-gateway.entity.ts +169 -0
- package/src/infrastructure/domain/workflows.entity.ts +108 -0
- package/src/infrastructure/middleware/index.ts +405 -0
- package/src/infrastructure/router/index.ts +549 -0
- package/src/infrastructure/services/ai-gateway/index.ts +416 -0
- package/src/infrastructure/services/analytics/analytics.service.ts +189 -0
- package/src/infrastructure/services/analytics/index.ts +7 -0
- package/src/infrastructure/services/d1/d1.service.ts +191 -0
- package/src/infrastructure/services/d1/index.ts +7 -0
- package/src/infrastructure/services/images/images.service.ts +227 -0
- package/src/infrastructure/services/images/index.ts +7 -0
- package/src/infrastructure/services/kv/index.ts +7 -0
- package/src/infrastructure/services/kv/kv.service.ts +116 -0
- package/src/infrastructure/services/r2/index.ts +7 -0
- package/src/infrastructure/services/r2/r2.service.ts +164 -0
- package/src/infrastructure/services/workers/index.ts +7 -0
- package/src/infrastructure/services/workers/workers.service.ts +164 -0
- package/src/infrastructure/services/workflows/index.ts +437 -0
- package/src/infrastructure/utils/helpers.ts +732 -0
- package/src/infrastructure/utils/index.ts +6 -0
- package/src/infrastructure/utils/utils.util.ts +150 -0
- package/src/presentation/hooks/cloudflare.hooks.ts +314 -0
- package/src/presentation/hooks/index.ts +6 -0
- package/src/worker.example.ts +41 -0
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Utility Functions
|
|
3
|
+
* @description Helper functions for Cloudflare Workers
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ============================================================
|
|
7
|
+
// Request Utilities
|
|
8
|
+
// ============================================================
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse request body
|
|
12
|
+
*/
|
|
13
|
+
export async function parseBody<T = unknown>(request: Request): Promise<T> {
|
|
14
|
+
const contentType = request.headers.get('Content-Type') || '';
|
|
15
|
+
|
|
16
|
+
if (contentType.includes('application/json')) {
|
|
17
|
+
return request.json() as Promise<T>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
21
|
+
const formData = await request.formData();
|
|
22
|
+
return Object.fromEntries(formData) as T;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (contentType.includes('text/')) {
|
|
26
|
+
return request.text() as unknown as T;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
throw new Error(`Unsupported content type: ${contentType}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get client IP
|
|
34
|
+
*/
|
|
35
|
+
export function getClientIP(request: Request): string {
|
|
36
|
+
return (
|
|
37
|
+
request.headers.get('CF-Connecting-IP') ||
|
|
38
|
+
request.headers.get('X-Forwarded-For') ||
|
|
39
|
+
'unknown'
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get client country
|
|
45
|
+
*/
|
|
46
|
+
export function getClientCountry(request: Request): string | null {
|
|
47
|
+
return request.headers.get('CF-IPCountry');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get request timestamp
|
|
52
|
+
*/
|
|
53
|
+
export function getRequestTimestamp(request: Request): number {
|
|
54
|
+
const cfDate = request.headers.get('CF-Ray');
|
|
55
|
+
if (cfDate) {
|
|
56
|
+
// Extract timestamp from CF-Ray if available
|
|
57
|
+
return Date.now();
|
|
58
|
+
}
|
|
59
|
+
return Date.now();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check if request is HTTPS
|
|
64
|
+
*/
|
|
65
|
+
export function isHTTPS(request: Request): boolean {
|
|
66
|
+
const url = new URL(request.url);
|
|
67
|
+
return url.protocol === 'https:';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Check if request is from specific origin
|
|
72
|
+
*/
|
|
73
|
+
export function isFromOrigin(request: Request, origin: string): boolean {
|
|
74
|
+
const requestOrigin = request.headers.get('Origin');
|
|
75
|
+
return requestOrigin === origin;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get user agent
|
|
80
|
+
*/
|
|
81
|
+
export function getUserAgent(request: Request): string {
|
|
82
|
+
return request.headers.get('User-Agent') || 'unknown';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Parse accept language
|
|
87
|
+
*/
|
|
88
|
+
export function parseAcceptLanguage(request: Request): string[] {
|
|
89
|
+
const acceptLanguage = request.headers.get('Accept-Language') || '';
|
|
90
|
+
return acceptLanguage
|
|
91
|
+
.split(',')
|
|
92
|
+
.map((lang) => lang.split(';')[0].trim());
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ============================================================
|
|
96
|
+
// Response Utilities
|
|
97
|
+
// ============================================================
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Create JSON response
|
|
101
|
+
*/
|
|
102
|
+
export function json<T>(data: T, status: number = 200): Response {
|
|
103
|
+
return Response.json(data, { status });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Create error response
|
|
108
|
+
*/
|
|
109
|
+
export function error(
|
|
110
|
+
message: string,
|
|
111
|
+
status: number = 500,
|
|
112
|
+
details?: Record<string, unknown>
|
|
113
|
+
): Response {
|
|
114
|
+
return Response.json(
|
|
115
|
+
{
|
|
116
|
+
error: message,
|
|
117
|
+
status,
|
|
118
|
+
...(details && { details }),
|
|
119
|
+
},
|
|
120
|
+
{ status }
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Create not found response
|
|
126
|
+
*/
|
|
127
|
+
export function notFound(message: string = 'Not Found'): Response {
|
|
128
|
+
return error(message, 404);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Create unauthorized response
|
|
133
|
+
*/
|
|
134
|
+
export function unauthorized(message: string = 'Unauthorized'): Response {
|
|
135
|
+
return error(message, 401);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Create forbidden response
|
|
140
|
+
*/
|
|
141
|
+
export function forbidden(message: string = 'Forbidden'): Response {
|
|
142
|
+
return error(message, 403);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Create bad request response
|
|
147
|
+
*/
|
|
148
|
+
export function badRequest(message: string = 'Bad Request', details?: Record<string, unknown>): Response {
|
|
149
|
+
return error(message, 400, details);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Create redirect response
|
|
154
|
+
*/
|
|
155
|
+
export function redirect(url: string, status: number = 302): Response {
|
|
156
|
+
return Response.redirect(url, status);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Create no-content response
|
|
161
|
+
*/
|
|
162
|
+
export function noContent(): Response {
|
|
163
|
+
return new Response(null, { status: 204 });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Create HTML response
|
|
168
|
+
*/
|
|
169
|
+
export function html(content: string, status: number = 200): Response {
|
|
170
|
+
return new Response(content, {
|
|
171
|
+
status,
|
|
172
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Create text response
|
|
178
|
+
*/
|
|
179
|
+
export function text(content: string, status: number = 200): Response {
|
|
180
|
+
return new Response(content, {
|
|
181
|
+
status,
|
|
182
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Create file response
|
|
188
|
+
*/
|
|
189
|
+
export function file(
|
|
190
|
+
content: ArrayBuffer,
|
|
191
|
+
contentType: string,
|
|
192
|
+
filename?: string
|
|
193
|
+
): Response {
|
|
194
|
+
const headers = new Headers({ 'Content-Type': contentType });
|
|
195
|
+
|
|
196
|
+
if (filename) {
|
|
197
|
+
headers.set('Content-Disposition', `attachment; filename="${filename}"`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return new Response(content, { headers });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Stream response
|
|
205
|
+
*/
|
|
206
|
+
export function stream(
|
|
207
|
+
readableStream: ReadableStream,
|
|
208
|
+
contentType: string = 'application/octet-stream'
|
|
209
|
+
): Response {
|
|
210
|
+
return new Response(readableStream, {
|
|
211
|
+
headers: { 'Content-Type': contentType },
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ============================================================
|
|
216
|
+
// Validation Utilities
|
|
217
|
+
// ============================================================
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Validate email
|
|
221
|
+
*/
|
|
222
|
+
export function isValidEmail(email: string): boolean {
|
|
223
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
224
|
+
return emailRegex.test(email);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Validate URL
|
|
229
|
+
*/
|
|
230
|
+
export function isValidURL(url: string): boolean {
|
|
231
|
+
try {
|
|
232
|
+
new URL(url);
|
|
233
|
+
return true;
|
|
234
|
+
} catch {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Validate UUID
|
|
241
|
+
*/
|
|
242
|
+
export function isValidUUID(uuid: string): boolean {
|
|
243
|
+
const uuidRegex =
|
|
244
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
245
|
+
return uuidRegex.test(uuid);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Validate phone number (basic)
|
|
250
|
+
*/
|
|
251
|
+
export function isValidPhone(phone: string): boolean {
|
|
252
|
+
const phoneRegex = /^\+?[\d\s\-()]+$/;
|
|
253
|
+
return phoneRegex.test(phone) && phone.replace(/\D/g, '').length >= 10;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Sanitize string input
|
|
258
|
+
*/
|
|
259
|
+
export function sanitize(input: string): string {
|
|
260
|
+
return input
|
|
261
|
+
.replace(/</g, '<')
|
|
262
|
+
.replace(/>/g, '>')
|
|
263
|
+
.replace(/"/g, '"')
|
|
264
|
+
.replace(/'/g, ''')
|
|
265
|
+
.replace(/\//g, '/');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Truncate text
|
|
270
|
+
*/
|
|
271
|
+
export function truncate(text: string, length: number, suffix: string = '...'): string {
|
|
272
|
+
if (text.length <= length) return text;
|
|
273
|
+
return text.substring(0, length - suffix.length) + suffix;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Slugify text
|
|
278
|
+
*/
|
|
279
|
+
export function slugify(text: string): string {
|
|
280
|
+
return text
|
|
281
|
+
.toLowerCase()
|
|
282
|
+
.trim()
|
|
283
|
+
.replace(/[^\w\s-]/g, '')
|
|
284
|
+
.replace(/[\s_-]+/g, '-')
|
|
285
|
+
.replace(/^-+|-+$/g, '');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ============================================================
|
|
289
|
+
// Cache Utilities
|
|
290
|
+
// ============================================================
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Generate cache key
|
|
294
|
+
*/
|
|
295
|
+
export function generateCacheKey(request: Request, prefix?: string): string {
|
|
296
|
+
const url = new URL(request.url);
|
|
297
|
+
const parts = [prefix || 'cache', url.pathname];
|
|
298
|
+
|
|
299
|
+
// Add query params (sorted for consistency)
|
|
300
|
+
const sortedParams = Array.from(url.searchParams.entries()).sort(
|
|
301
|
+
([a], [b]) => a.localeCompare(b)
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
if (sortedParams.length > 0) {
|
|
305
|
+
parts.push(
|
|
306
|
+
sortedParams
|
|
307
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
308
|
+
.join('&')
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Add auth header if present (for user-specific caching)
|
|
313
|
+
const auth = request.headers.get('Authorization');
|
|
314
|
+
if (auth) {
|
|
315
|
+
parts.push(auth.substring(0, 20)); // First 20 chars of auth
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return parts.join(':');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Generate hash
|
|
323
|
+
*/
|
|
324
|
+
export async function hash(input: string): Promise<string> {
|
|
325
|
+
const encoder = new TextEncoder();
|
|
326
|
+
const data = encoder.encode(input);
|
|
327
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
328
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
329
|
+
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Parse cache control header
|
|
334
|
+
*/
|
|
335
|
+
export function parseCacheControl(header: string): {
|
|
336
|
+
maxAge?: number;
|
|
337
|
+
noCache?: boolean;
|
|
338
|
+
noStore?: boolean;
|
|
339
|
+
mustRevalidate?: boolean;
|
|
340
|
+
} {
|
|
341
|
+
const directives = header.split(',').map((d) => d.trim());
|
|
342
|
+
const result: Record<string, boolean | number> = {};
|
|
343
|
+
|
|
344
|
+
for (const directive of directives) {
|
|
345
|
+
const [key, value] = directive.split('=');
|
|
346
|
+
|
|
347
|
+
switch (key.toLowerCase()) {
|
|
348
|
+
case 'max-age':
|
|
349
|
+
result.maxAge = parseInt(value, 10);
|
|
350
|
+
break;
|
|
351
|
+
case 'no-cache':
|
|
352
|
+
result.noCache = true;
|
|
353
|
+
break;
|
|
354
|
+
case 'no-store':
|
|
355
|
+
result.noStore = true;
|
|
356
|
+
break;
|
|
357
|
+
case 'must-revalidate':
|
|
358
|
+
result.mustRevalidate = true;
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return result as {
|
|
364
|
+
maxAge?: number;
|
|
365
|
+
noCache?: boolean;
|
|
366
|
+
noStore?: boolean;
|
|
367
|
+
mustRevalidate?: boolean;
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ============================================================
|
|
372
|
+
// Time Utilities
|
|
373
|
+
// ============================================================
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Parse duration string to seconds
|
|
377
|
+
*/
|
|
378
|
+
export function parseDuration(duration: string): number {
|
|
379
|
+
const match = duration.match(/^(\d+)(s|m|h|d)$/);
|
|
380
|
+
|
|
381
|
+
if (!match) {
|
|
382
|
+
throw new Error(`Invalid duration format: ${duration}`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const value = parseInt(match[1], 10);
|
|
386
|
+
const unit = match[2];
|
|
387
|
+
|
|
388
|
+
switch (unit) {
|
|
389
|
+
case 's':
|
|
390
|
+
return value;
|
|
391
|
+
case 'm':
|
|
392
|
+
return value * 60;
|
|
393
|
+
case 'h':
|
|
394
|
+
return value * 3600;
|
|
395
|
+
case 'd':
|
|
396
|
+
return value * 86400;
|
|
397
|
+
default:
|
|
398
|
+
throw new Error(`Invalid duration unit: ${unit}`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Format duration
|
|
404
|
+
*/
|
|
405
|
+
export function formatDuration(seconds: number): string {
|
|
406
|
+
if (seconds < 60) return `${seconds}s`;
|
|
407
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
408
|
+
if (seconds < 86400)
|
|
409
|
+
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
|
410
|
+
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Sleep utility
|
|
415
|
+
*/
|
|
416
|
+
export function sleep(ms: number): Promise<void> {
|
|
417
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Retry with exponential backoff
|
|
422
|
+
*/
|
|
423
|
+
export async function retry<T>(
|
|
424
|
+
fn: () => Promise<T>,
|
|
425
|
+
options: {
|
|
426
|
+
maxAttempts?: number;
|
|
427
|
+
initialDelay?: number;
|
|
428
|
+
maxDelay?: number;
|
|
429
|
+
backoffMultiplier?: number;
|
|
430
|
+
} = {}
|
|
431
|
+
): Promise<T> {
|
|
432
|
+
const {
|
|
433
|
+
maxAttempts = 3,
|
|
434
|
+
initialDelay = 1000,
|
|
435
|
+
maxDelay = 10000,
|
|
436
|
+
backoffMultiplier = 2,
|
|
437
|
+
} = options;
|
|
438
|
+
|
|
439
|
+
let lastError: Error | undefined;
|
|
440
|
+
|
|
441
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
442
|
+
try {
|
|
443
|
+
return await fn();
|
|
444
|
+
} catch (error) {
|
|
445
|
+
lastError = error as Error;
|
|
446
|
+
|
|
447
|
+
if (attempt < maxAttempts - 1) {
|
|
448
|
+
const delay = Math.min(
|
|
449
|
+
initialDelay * Math.pow(backoffMultiplier, attempt),
|
|
450
|
+
maxDelay
|
|
451
|
+
);
|
|
452
|
+
await sleep(delay);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
throw lastError;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ============================================================
|
|
461
|
+
// URL Utilities
|
|
462
|
+
// ============================================================
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Build URL with query params
|
|
466
|
+
*/
|
|
467
|
+
export function buildURL(base: string, params: Record<string, string | number | boolean | undefined>): string {
|
|
468
|
+
const url = new URL(base);
|
|
469
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
470
|
+
if (value !== undefined) {
|
|
471
|
+
url.searchParams.set(key, String(value));
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
return url.toString();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Parse query params
|
|
479
|
+
*/
|
|
480
|
+
export function parseQueryParams(url: string): Record<string, string> {
|
|
481
|
+
const params = new URL(url).searchParams;
|
|
482
|
+
return Object.fromEntries(params.entries());
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Join URL paths
|
|
487
|
+
*/
|
|
488
|
+
export function joinPath(...parts: string[]): string {
|
|
489
|
+
return parts
|
|
490
|
+
.map((part) => part.replace(/^\/+|\/+$/g, ''))
|
|
491
|
+
.filter(Boolean)
|
|
492
|
+
.join('/');
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ============================================================
|
|
496
|
+
// Encoding Utilities
|
|
497
|
+
// ============================================================
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Base64 encode
|
|
501
|
+
*/
|
|
502
|
+
export function base64Encode(input: string): string {
|
|
503
|
+
return btoa(input);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Base64 decode
|
|
508
|
+
*/
|
|
509
|
+
export function base64Decode(input: string): string {
|
|
510
|
+
return atob(input);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Base64 URL encode
|
|
515
|
+
*/
|
|
516
|
+
export function base64URLEncode(input: string): string {
|
|
517
|
+
return base64Encode(input)
|
|
518
|
+
.replace(/\+/g, '-')
|
|
519
|
+
.replace(/\//g, '_')
|
|
520
|
+
.replace(/=/g, '');
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Base64 URL decode
|
|
525
|
+
*/
|
|
526
|
+
export function base64URLDecode(input: string): string {
|
|
527
|
+
let base64 = input.replace(/-/g, '+').replace(/_/g, '/');
|
|
528
|
+
|
|
529
|
+
while (base64.length % 4) {
|
|
530
|
+
base64 += '=';
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return base64Decode(base64);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* URL safe encode
|
|
538
|
+
*/
|
|
539
|
+
export function urlSafeEncode(input: string): string {
|
|
540
|
+
return encodeURIComponent(input);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* URL safe decode
|
|
545
|
+
*/
|
|
546
|
+
export function urlSafeDecode(input: string): string {
|
|
547
|
+
return decodeURIComponent(input);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ============================================================
|
|
551
|
+
// Array Utilities
|
|
552
|
+
// ============================================================
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Chunk array
|
|
556
|
+
*/
|
|
557
|
+
export function chunk<T>(array: T[], size: number): T[][] {
|
|
558
|
+
const chunks: T[][] = [];
|
|
559
|
+
for (let i = 0; i < array.length; i += size) {
|
|
560
|
+
chunks.push(array.slice(i, i + size));
|
|
561
|
+
}
|
|
562
|
+
return chunks;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Shuffle array
|
|
567
|
+
*/
|
|
568
|
+
export function shuffle<T>(array: T[]): T[] {
|
|
569
|
+
const result = [...array];
|
|
570
|
+
for (let i = result.length - 1; i > 0; i--) {
|
|
571
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
572
|
+
[result[i], result[j]] = [result[j], result[i]];
|
|
573
|
+
}
|
|
574
|
+
return result;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Unique array
|
|
579
|
+
*/
|
|
580
|
+
export function unique<T>(array: T[]): T[] {
|
|
581
|
+
return Array.from(new Set(array));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Group array by key
|
|
586
|
+
*/
|
|
587
|
+
export function groupBy<T>(
|
|
588
|
+
array: T[],
|
|
589
|
+
key: keyof T
|
|
590
|
+
): Record<string, T[]> {
|
|
591
|
+
return array.reduce((result, item) => {
|
|
592
|
+
const group = String(item[key]);
|
|
593
|
+
if (!result[group]) {
|
|
594
|
+
result[group] = [];
|
|
595
|
+
}
|
|
596
|
+
result[group].push(item);
|
|
597
|
+
return result;
|
|
598
|
+
}, {} as Record<string, T[]>);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ============================================================
|
|
602
|
+
// Object Utilities
|
|
603
|
+
// ============================================================
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Deep clone object
|
|
607
|
+
*/
|
|
608
|
+
export function deepClone<T>(obj: T): T {
|
|
609
|
+
return JSON.parse(JSON.stringify(obj));
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Deep merge objects
|
|
614
|
+
*/
|
|
615
|
+
export function deepMerge<T extends Record<string, any>>(target: T, ...sources: Partial<T>[]): T {
|
|
616
|
+
if (!sources.length) return target;
|
|
617
|
+
const source = sources.shift();
|
|
618
|
+
|
|
619
|
+
if (isObject(target) && isObject(source)) {
|
|
620
|
+
for (const key in source) {
|
|
621
|
+
if (isObject(source[key])) {
|
|
622
|
+
if (!target[key]) Object.assign(target, { [key]: {} });
|
|
623
|
+
deepMerge(target[key], source[key]);
|
|
624
|
+
} else {
|
|
625
|
+
Object.assign(target, { [key]: source[key] });
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return deepMerge(target, ...sources);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function isObject(item: unknown): item is Record<string, unknown> {
|
|
634
|
+
return Boolean(item && typeof item === 'object' && !Array.isArray(item));
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Pick properties from object
|
|
639
|
+
*/
|
|
640
|
+
export function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
|
|
641
|
+
const result = {} as Pick<T, K>;
|
|
642
|
+
keys.forEach((key) => {
|
|
643
|
+
if (key in obj) {
|
|
644
|
+
result[key] = obj[key];
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
return result;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Omit properties from object
|
|
652
|
+
*/
|
|
653
|
+
export function omit<T, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
|
|
654
|
+
const result = { ...obj };
|
|
655
|
+
keys.forEach((key) => {
|
|
656
|
+
delete result[key];
|
|
657
|
+
});
|
|
658
|
+
return result as Omit<T, K>;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ============================================================
|
|
662
|
+
// Random Utilities
|
|
663
|
+
// ============================================================
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Generate random string
|
|
667
|
+
*/
|
|
668
|
+
export function randomString(length: number = 16): string {
|
|
669
|
+
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
670
|
+
let result = '';
|
|
671
|
+
for (let i = 0; i < length; i++) {
|
|
672
|
+
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
673
|
+
}
|
|
674
|
+
return result;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Generate random ID
|
|
679
|
+
*/
|
|
680
|
+
export function randomID(prefix: string = ''): string {
|
|
681
|
+
const timestamp = Date.now().toString(36);
|
|
682
|
+
const random = Math.random().toString(36).substring(2, 9);
|
|
683
|
+
return prefix ? `${prefix}_${timestamp}${random}` : `${timestamp}${random}`;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Random item from array
|
|
688
|
+
*/
|
|
689
|
+
export function randomItem<T>(array: T[]): T {
|
|
690
|
+
return array[Math.floor(Math.random() * array.length)];
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Random number in range
|
|
695
|
+
*/
|
|
696
|
+
export function randomInRange(min: number, max: number): number {
|
|
697
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// ============================================================
|
|
701
|
+
// Type Guards
|
|
702
|
+
// ============================================================
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Check if value is defined
|
|
706
|
+
*/
|
|
707
|
+
export function isDefined<T>(value: T | null | undefined): value is T {
|
|
708
|
+
return value !== null && value !== undefined;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Check if value is empty
|
|
713
|
+
*/
|
|
714
|
+
export function isEmpty(value: unknown): boolean {
|
|
715
|
+
if (value === null || value === undefined) return true;
|
|
716
|
+
if (typeof value === 'string') return value.trim().length === 0;
|
|
717
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
718
|
+
if (typeof value === 'object') return Object.keys(value).length === 0;
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Check if value is a plain object
|
|
724
|
+
*/
|
|
725
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
726
|
+
return (
|
|
727
|
+
typeof value === 'object' &&
|
|
728
|
+
value !== null &&
|
|
729
|
+
!Array.isArray(value) &&
|
|
730
|
+
Object.prototype.toString.call(value) === '[object Object]'
|
|
731
|
+
);
|
|
732
|
+
}
|