@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,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D1 Service
|
|
3
|
+
* @description Cloudflare D1 database operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { D1QueryResult, D1BatchResult } from "../../../domain/entities/d1.entity";
|
|
7
|
+
import type { ID1Service } from "../../../domain/interfaces/services.interface";
|
|
8
|
+
|
|
9
|
+
export interface D1ExecOptions {
|
|
10
|
+
readonly binding?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class D1Service implements ID1Service {
|
|
14
|
+
private databases: Map<string, D1Database> = new Map();
|
|
15
|
+
|
|
16
|
+
bindDatabase(name: string, database: D1Database): void {
|
|
17
|
+
this.databases.set(name, database);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private getDatabase(binding?: string): D1Database {
|
|
21
|
+
const name = binding || "default";
|
|
22
|
+
const database = this.databases.get(name);
|
|
23
|
+
if (!database) {
|
|
24
|
+
throw new Error(`D1 database "${name}" not bound`);
|
|
25
|
+
}
|
|
26
|
+
return database;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async query<T>(sql: string, params?: readonly unknown[], binding?: string): Promise<D1QueryResult<T>> {
|
|
30
|
+
const database = this.getDatabase(binding);
|
|
31
|
+
|
|
32
|
+
const stmt = database.prepare(sql);
|
|
33
|
+
const result = params ? await stmt.bind(...params).all() : await stmt.all();
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
results: result.results as T[],
|
|
37
|
+
success: result.success,
|
|
38
|
+
meta: result.meta
|
|
39
|
+
? {
|
|
40
|
+
duration: result.meta.duration,
|
|
41
|
+
rows_read: result.meta.rows_read,
|
|
42
|
+
rows_written: result.meta.rows_written,
|
|
43
|
+
last_row_id: result.meta.last_row_id,
|
|
44
|
+
changes: result.meta.changes,
|
|
45
|
+
}
|
|
46
|
+
: undefined,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async batch(
|
|
51
|
+
statements: readonly { sql: string; params?: readonly unknown[]; binding?: string }[]
|
|
52
|
+
): Promise<D1BatchResult> {
|
|
53
|
+
// Group by binding
|
|
54
|
+
const grouped = new Map<string, D1PreparedStatement[]>();
|
|
55
|
+
|
|
56
|
+
for (const stmt of statements) {
|
|
57
|
+
const binding = stmt.binding || "default";
|
|
58
|
+
const database = this.getDatabase(binding);
|
|
59
|
+
|
|
60
|
+
if (!grouped.has(binding)) {
|
|
61
|
+
grouped.set(binding, []);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
grouped.get(binding)!.push(database.prepare(stmt.sql));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Note: D1 batch requires all statements to be from the same database
|
|
68
|
+
if (grouped.size > 1) {
|
|
69
|
+
throw new Error("Batch operations must be from the same database binding");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const [binding, statements] = Array.from(grouped.entries())[0];
|
|
73
|
+
const database = this.getDatabase(binding);
|
|
74
|
+
|
|
75
|
+
const results = await database.batch(statements as D1PreparedStatement[]);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
success: results.every((r) => r.success),
|
|
79
|
+
results: results.map((r) => ({
|
|
80
|
+
results: r.results,
|
|
81
|
+
success: r.success,
|
|
82
|
+
meta: r.meta,
|
|
83
|
+
})),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async execute<T>(sql: string, params?: readonly unknown[], binding?: string): Promise<D1QueryResult<T>> {
|
|
88
|
+
return this.query<T>(sql, params, binding);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Query helpers
|
|
93
|
+
*/
|
|
94
|
+
async findOne<T>(sql: string, params?: readonly unknown[], binding?: string): Promise<T | null> {
|
|
95
|
+
const result = await this.query<T>(sql, params, binding);
|
|
96
|
+
|
|
97
|
+
return (result.results[0] as T) ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async insert<T>(
|
|
101
|
+
table: string,
|
|
102
|
+
data: Record<string, unknown>,
|
|
103
|
+
binding?: string
|
|
104
|
+
): Promise<D1QueryResult<T>> {
|
|
105
|
+
const columns = Object.keys(data);
|
|
106
|
+
const values = Object.values(data);
|
|
107
|
+
const placeholders = values.map(() => "?").join(", ");
|
|
108
|
+
|
|
109
|
+
const sql = `INSERT INTO ${table} (${columns.join(", ")}) VALUES (${placeholders})`;
|
|
110
|
+
|
|
111
|
+
return this.query<T>(sql, values, binding);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async update<T>(
|
|
115
|
+
table: string,
|
|
116
|
+
data: Record<string, unknown>,
|
|
117
|
+
where: string,
|
|
118
|
+
whereParams: readonly unknown[] = [],
|
|
119
|
+
binding?: string
|
|
120
|
+
): Promise<D1QueryResult<T>> {
|
|
121
|
+
const columns = Object.keys(data);
|
|
122
|
+
const values = Object.values(data);
|
|
123
|
+
const setClause = columns.map((col) => `${col} = ?`).join(", ");
|
|
124
|
+
|
|
125
|
+
const sql = `UPDATE ${table} SET ${setClause} WHERE ${where}`;
|
|
126
|
+
const params = [...values, ...whereParams];
|
|
127
|
+
|
|
128
|
+
return this.query<T>(sql, params, binding);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async delete(table: string, where: string, params?: readonly unknown[], binding?: string): Promise<D1QueryResult> {
|
|
132
|
+
const sql = `DELETE FROM ${table} WHERE ${where}`;
|
|
133
|
+
|
|
134
|
+
return this.query(sql, params, binding);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Schema helpers
|
|
139
|
+
*/
|
|
140
|
+
async createTable(
|
|
141
|
+
table: string,
|
|
142
|
+
columns: Record<string, string>,
|
|
143
|
+
binding?: string
|
|
144
|
+
): Promise<void> {
|
|
145
|
+
const columnDefs = Object.entries(columns)
|
|
146
|
+
.map(([name, type]) => `${name} ${type}`)
|
|
147
|
+
.join(", ");
|
|
148
|
+
|
|
149
|
+
const sql = `CREATE TABLE IF NOT EXISTS ${table} (${columnDefs})`;
|
|
150
|
+
|
|
151
|
+
await this.execute(sql, [], binding);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async dropTable(table: string, binding?: string): Promise<void> {
|
|
155
|
+
const sql = `DROP TABLE IF EXISTS ${table}`;
|
|
156
|
+
|
|
157
|
+
await this.execute(sql, [], binding);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async tableExists(table: string, binding?: string): Promise<boolean> {
|
|
161
|
+
const sql = `
|
|
162
|
+
SELECT name FROM sqlite_master
|
|
163
|
+
WHERE type='table' AND name=?
|
|
164
|
+
`;
|
|
165
|
+
|
|
166
|
+
const result = await this.query<{ name: string }>(sql, [table], binding);
|
|
167
|
+
|
|
168
|
+
return result.results.length > 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Transaction helpers
|
|
173
|
+
*/
|
|
174
|
+
async runInTransaction<T>(
|
|
175
|
+
callback: (txn: D1Transaction) => Promise<T>,
|
|
176
|
+
binding?: string
|
|
177
|
+
): Promise<T> {
|
|
178
|
+
const database = this.getDatabase(binding);
|
|
179
|
+
|
|
180
|
+
// Note: D1 doesn't have explicit transaction API
|
|
181
|
+
// Use batch operations or implement application-level logic
|
|
182
|
+
|
|
183
|
+
throw new Error("Transactions not yet implemented for D1");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface D1Transaction {
|
|
188
|
+
query<T>(sql: string, params?: readonly unknown[]): Promise<D1QueryResult<T>>;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const d1Service = new D1Service();
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Images Service
|
|
3
|
+
* @description Cloudflare Images operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ImageUploadResult, ImageUploadOptions, ImageTransformation, SignedURL } from "../../../domain/entities/image.entity";
|
|
7
|
+
import type { IImageService } from "../../../domain/interfaces/services.interface";
|
|
8
|
+
import { validationUtils, transformUtils } from "../../utils";
|
|
9
|
+
import { MAX_IMAGE_SIZE, ALLOWED_IMAGE_TYPES } from "../../constants";
|
|
10
|
+
|
|
11
|
+
export interface ImagesClientOptions {
|
|
12
|
+
readonly accountId: string;
|
|
13
|
+
readonly apiToken: string;
|
|
14
|
+
readonly customDomain?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class ImagesService implements IImageService {
|
|
18
|
+
private accountId: string | null = null;
|
|
19
|
+
private apiToken: string | null = null;
|
|
20
|
+
private customDomain: string | null = null;
|
|
21
|
+
|
|
22
|
+
initialize(options: ImagesClientOptions): void {
|
|
23
|
+
this.accountId = options.accountId;
|
|
24
|
+
this.apiToken = options.apiToken;
|
|
25
|
+
this.customDomain = options.customDomain ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private getAPIBaseURL(): string {
|
|
29
|
+
if (!this.accountId) {
|
|
30
|
+
throw new Error("ImagesService not initialized");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/images/v1`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private getAuthHeaders(): HeadersInit {
|
|
37
|
+
if (!this.apiToken) {
|
|
38
|
+
throw new Error("ImagesService not initialized");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
Authorization: `Bearer ${this.apiToken}`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async upload(file: File | Blob, options?: ImageUploadOptions): Promise<ImageUploadResult> {
|
|
47
|
+
// Validate file
|
|
48
|
+
if (file instanceof File) {
|
|
49
|
+
if (!validationUtils.isValidImageType(file.type)) {
|
|
50
|
+
throw new Error(`Invalid image type: ${file.type}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!validationUtils.isValidImageSize(file.size, MAX_IMAGE_SIZE)) {
|
|
54
|
+
throw new Error(`Image size exceeds maximum: ${MAX_IMAGE_SIZE}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Prepare form data
|
|
59
|
+
const formData = new FormData();
|
|
60
|
+
formData.append("file", file);
|
|
61
|
+
|
|
62
|
+
if (options?.metadata) {
|
|
63
|
+
for (const [key, value] of Object.entries(options.metadata)) {
|
|
64
|
+
formData.append(`metadata[${key}]`, value);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (options?.requireSignedURLs !== undefined) {
|
|
69
|
+
formData.append("requireSignedURLs", options.requireSignedURLs.toString());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Upload
|
|
73
|
+
const response = await fetch(`${this.getAPIBaseURL()}/direct_upload`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: this.getAuthHeaders(),
|
|
76
|
+
body: formData,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
const error = await response.text();
|
|
81
|
+
throw new Error(`Upload failed: ${error}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const data = await response.json();
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
id: data.result.id,
|
|
88
|
+
filename: data.result.filename,
|
|
89
|
+
uploaded: new Date(data.result.uploaded),
|
|
90
|
+
variants: data.result.variants,
|
|
91
|
+
requireSignedURLs: data.result.requireSignedURLs,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async getSignedURL(imageId: string, expiresIn = 3600): Promise<SignedURL> {
|
|
96
|
+
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
|
97
|
+
|
|
98
|
+
const response = await fetch(`${this.getAPIBaseURL()}/${imageId}`, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: {
|
|
101
|
+
...this.getAuthHeaders(),
|
|
102
|
+
"Content-Type": "application/json",
|
|
103
|
+
},
|
|
104
|
+
body: JSON.stringify({
|
|
105
|
+
expiry: expiresAt.toISOString(),
|
|
106
|
+
}),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
const error = await response.text();
|
|
111
|
+
throw new Error(`Failed to get signed URL: ${error}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const data = await response.json();
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
url: data.result.signedURLs?.[0] || data.result.variants?.[0],
|
|
118
|
+
expiresAt,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async getTransformedURL(imageId: string, transform: ImageTransformation): Promise<string> {
|
|
123
|
+
if (this.customDomain) {
|
|
124
|
+
const baseURL = `https://${this.customDomain}/${imageId}`;
|
|
125
|
+
return transformUtils.generateTransformURL(baseURL, transform);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Fallback to Cloudflare CDN URL
|
|
129
|
+
const baseURL = `https://imagedelivery.net/${this.accountId}/${imageId}/public`;
|
|
130
|
+
return transformUtils.generateTransformURL(baseURL, transform);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async delete(imageId: string): Promise<boolean> {
|
|
134
|
+
const response = await fetch(`${this.getAPIBaseURL()}/${imageId}`, {
|
|
135
|
+
method: "DELETE",
|
|
136
|
+
headers: this.getAuthHeaders(),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
const error = await response.text();
|
|
141
|
+
throw new Error(`Delete failed: ${error}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* List helpers
|
|
149
|
+
*/
|
|
150
|
+
async listImages(options?: { page?: number; perPage?: number }): Promise<{
|
|
151
|
+
images: readonly ImageUploadResult[];
|
|
152
|
+
totalCount: number;
|
|
153
|
+
}> {
|
|
154
|
+
const params = new URLSearchParams();
|
|
155
|
+
if (options?.page) params.set("page", options.page.toString());
|
|
156
|
+
if (options?.perPage) params.set("per_page", options.perPage.toString());
|
|
157
|
+
|
|
158
|
+
const response = await fetch(`${this.getAPIBaseURL()}?${params}`, {
|
|
159
|
+
headers: this.getAuthHeaders(),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (!response.ok) {
|
|
163
|
+
const error = await response.text();
|
|
164
|
+
throw new Error(`List failed: ${error}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const data = await response.json();
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
images: data.result.images,
|
|
171
|
+
totalCount: data.result.totalCount,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Bulk operations
|
|
177
|
+
*/
|
|
178
|
+
async uploadMultiple(files: readonly (File | Blob)[], options?: ImageUploadOptions): Promise<ImageUploadResult[]> {
|
|
179
|
+
const uploads = files.map((file) => this.upload(file, options));
|
|
180
|
+
|
|
181
|
+
return Promise.all(uploads);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async deleteMultiple(imageIds: readonly string[]): Promise<boolean> {
|
|
185
|
+
const deletions = imageIds.map((id) => this.delete(id));
|
|
186
|
+
|
|
187
|
+
await Promise.all(deletions);
|
|
188
|
+
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Variant helpers
|
|
194
|
+
*/
|
|
195
|
+
generateVariantURL(imageId: string, variant: string): string {
|
|
196
|
+
if (this.customDomain) {
|
|
197
|
+
return `https://${this.customDomain}/${imageId}/${variant}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return `https://imagedelivery.net/${this.accountId}/${imageId}/${variant}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Upload from URL
|
|
205
|
+
*/
|
|
206
|
+
async uploadFromURL(
|
|
207
|
+
url: string,
|
|
208
|
+
filename: string,
|
|
209
|
+
options?: ImageUploadOptions
|
|
210
|
+
): Promise<ImageUploadResult> {
|
|
211
|
+
// Fetch the image
|
|
212
|
+
const response = await fetch(url);
|
|
213
|
+
if (!response.ok) {
|
|
214
|
+
throw new Error(`Failed to fetch image: ${response.statusText}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const blob = await response.blob();
|
|
218
|
+
|
|
219
|
+
// Upload the blob
|
|
220
|
+
return this.upload(
|
|
221
|
+
new File([blob], filename, { type: blob.type || "image/jpeg" }),
|
|
222
|
+
options
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export const imagesService = new ImagesService();
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KV Service
|
|
3
|
+
* @description Cloudflare KV key-value storage operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { KVEntry, KVListOptions, KVListResult } from "../../../domain/entities/kv.entity";
|
|
7
|
+
import type { IKVService } from "../../../domain/interfaces/services.interface";
|
|
8
|
+
import { validationUtils, cacheUtils } from "../../utils";
|
|
9
|
+
|
|
10
|
+
export interface KVCacheOptions {
|
|
11
|
+
readonly namespace: string;
|
|
12
|
+
readonly defaultTTL?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
class KVService implements IKVService {
|
|
16
|
+
private namespaces: Map<string, KVNamespace> = new Map();
|
|
17
|
+
private defaultTTL: number = 3600;
|
|
18
|
+
|
|
19
|
+
initialize(options: KVCacheOptions): void {
|
|
20
|
+
// Namespaces are bound by Cloudflare at runtime
|
|
21
|
+
// This is just a reference to the binding name
|
|
22
|
+
this.defaultTTL = options.defaultTTL ?? 3600;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
bindNamespace(name: string, namespace: KVNamespace): void {
|
|
26
|
+
this.namespaces.set(name, namespace);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private getNamespace(binding?: string): KVNamespace {
|
|
30
|
+
const name = binding || "default";
|
|
31
|
+
const namespace = this.namespaces.get(name);
|
|
32
|
+
if (!namespace) {
|
|
33
|
+
throw new Error(`KV namespace "${name}" not bound`);
|
|
34
|
+
}
|
|
35
|
+
return namespace;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async get<T>(key: string, binding?: string): Promise<T | null> {
|
|
39
|
+
if (!validationUtils.isValidKVKey(key)) {
|
|
40
|
+
throw new Error(`Invalid KV key: ${key}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const namespace = this.getNamespace(binding);
|
|
44
|
+
const value = await namespace.get(key, "json");
|
|
45
|
+
|
|
46
|
+
return (value as T) ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async put<T>(key: string, value: T, options?: { ttl?: number; binding?: string }): Promise<void> {
|
|
50
|
+
if (!validationUtils.isValidKVKey(key)) {
|
|
51
|
+
throw new Error(`Invalid KV key: ${key}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const namespace = this.getNamespace(options?.binding);
|
|
55
|
+
const ttl = options?.ttl ?? this.defaultTTL;
|
|
56
|
+
|
|
57
|
+
await namespace.put(key, JSON.stringify(value), {
|
|
58
|
+
expirationTtl: ttl,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async delete(key: string, binding?: string): Promise<boolean> {
|
|
63
|
+
if (!validationUtils.isValidKVKey(key)) {
|
|
64
|
+
throw new Error(`Invalid KV key: ${key}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const namespace = this.getNamespace(binding);
|
|
68
|
+
await namespace.delete(key);
|
|
69
|
+
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async list(options?: KVListOptions & { binding?: string }): Promise<KVListResult> {
|
|
74
|
+
const namespace = this.getNamespace(options?.binding);
|
|
75
|
+
const list = await namespace.list({
|
|
76
|
+
limit: options?.limit,
|
|
77
|
+
cursor: options?.cursor,
|
|
78
|
+
prefix: options?.prefix,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
keys: list.keys,
|
|
83
|
+
list_complete: list.list_complete,
|
|
84
|
+
cursor: list.cursor,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Cache helpers
|
|
90
|
+
*/
|
|
91
|
+
async getOrSet<T>(
|
|
92
|
+
key: string,
|
|
93
|
+
factory: () => Promise<T>,
|
|
94
|
+
options?: { ttl?: number; binding?: string }
|
|
95
|
+
): Promise<T> {
|
|
96
|
+
const cached = await this.get<T>(key, options?.binding);
|
|
97
|
+
if (cached !== null) return cached;
|
|
98
|
+
|
|
99
|
+
const value = await factory();
|
|
100
|
+
await this.put(key, value, options);
|
|
101
|
+
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async invalidatePattern(prefix: string, binding?: string): Promise<void> {
|
|
106
|
+
const list = await this.list({ prefix, binding });
|
|
107
|
+
|
|
108
|
+
await Promise.all(
|
|
109
|
+
list.keys.map(async (key) => {
|
|
110
|
+
await this.delete(key.name, binding);
|
|
111
|
+
})
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const kvService = new KVService();
|