diff --git a/jm_video_ui.md b/jm_video_ui.md index 9f0c28b..3e7310f 100644 --- a/jm_video_ui.md +++ b/jm_video_ui.md @@ -1,568 +1,17 @@ -import glob -import mimetypes -import os -import threading -import time -import tkinter as tk -from tkinter import ttk, filedialog, scrolledtext, messagebox - -import requests -from loguru import logger -from qcloud_cos import CosConfig -from qcloud_cos import CosS3Client - -cos_bucket_name = 'sucai-1324682537' -cos_secret_id = 'AKIDsrihIyjZOBsjimt8TsN8yvv1AMh5dB44' -cos_secret_key = 'CPZcxdk6W39Jd4cGY95wvupoyMd0YFqW' -cos_region = 'ap-shanghai' - -api_key = '21575c22-14aa-40ca-8aa8-f00ca27a3a17' - -default_prompt = [ - "女人扭动身体向摄像机展示身材,一只手撩了一下头发,镜头从左向右移动并放大画面", - # "女人扭动身体向摄像机展示身材,一只手撩了一下头发后放在了裤子上,镜头从左向右移动", - "时尚模特抬头自信的展示身材,扭动身体,一只手放在了头上,镜头逐渐放大聚焦在了下衣上", - "女人扭动身体向摄像机展示身材,一只手撩了一下头发后放在了裤子上,镜头从左向右移动", - "自信步伐跟拍模特,模特步伐自信地同时行走,镜头紧紧跟随。抬起手捋一捋头发。传递出自信与时尚的气息。", - "女生两只手捏着拳头轻盈的左右摇摆跳舞,动作幅度不大,然后把手摊开放在胸口再做出像popping心脏跳动的动作,左右身体都要非常协调", - "一个年轻女子自信地在相机前展示了她优美的身材,以自然的流体动作自由地摇摆,左手撩了一下头发之后停在了胸前。一个美女自拍跳舞扭动身体的视频,手从下到上最后放在胸前,妩媚的表情", - "美女向后退了一步站在那里展示服装,双手轻轻提了一下裤子两侧,镜头从上到下逐渐放大", - "女人低头看向裤子,向镜头展示身材,一只手放在了头上做pose动作", - "美女向后退了一步站在那里展示服装,低头并用手抚摸裤子,镜头从上到下逐渐放大", - "美女向后退了一步站在那里展示服装,双手从上到下整理衣服,自然扭动身体,自信的表情" -] - -default_prompt_str = '\n'.join(default_prompt) - -# pyinstaller -F -w --name seed_video jm_video_ui.py -class VideoUtils: - - @staticmethod - def download_video(video_url: str, save_path: str) -> str: - try: - file_name = f'{int(time.time() * 1000)}.mp4' - response = requests.get(video_url, stream=True) - full_path = os.path.join(save_path, file_name) - with open(full_path, 'wb') as f: - for chunk in response.iter_content(chunk_size=1024 * 1024 * 5): - if chunk: - f.write(chunk) - return full_path - except Exception as e: - logger.error(e) - pass - - @staticmethod - def upload_file_to_cos(file_path: str, remove_src_file: bool = False): - resp_data = {'status': True, 'data': '', 'msg': ''} - mime_type, _ = mimetypes.guess_type(file_path) - category = mime_type.split('/')[0] - f_name = os.path.basename(file_path) - suffix = f_name.split('.')[-1] - real_name = f'{int(time.time() * 1000)}.{suffix}' - try: - object_key = f'tk/{category}/{real_name}' - config = CosConfig(Region=cos_region, SecretId=cos_secret_id, SecretKey=cos_secret_key) - client = CosS3Client(config) - _ = client.upload_file( - Bucket=cos_bucket_name, - Key=object_key, - LocalFilePath=file_path, - EnableMD5=False, - progress_callback=None - ) - url = f'https://{cos_bucket_name}.cos.ap-shanghai.myqcloud.com/{object_key}' - resp_data['data'] = url - resp_data['msg'] = '上传成功' - except Exception as e: - logger.error(e) - resp_data['status'] = False - resp_data['msg'] = str(e) - finally: - if remove_src_file: - os.remove(file_path) - return resp_data - - @staticmethod - def submit_task(prompt: str, img_url: str, duration: str = '5', model_type:str='lite'): - """ - :param prompt: 生成视频的提示词 - :param img_url: - :param duration: - :return: - """ - if model_type == 'lite': - model = 'doubao-seedance-1-0-lite-i2v-250428' - resolution = '720p' - else: - model = 'doubao-seedance-1-0-pro-250528' - resolution = '1080p' - if duration not in ('5', '10'): - logger.error('Duration must be either 5 or 10') - try: - headers = { - 'Content-Type': 'application/json', - 'Authorization': f'Bearer {api_key}', - } - - json_data = { - 'model': model, - 'content': [ - { - 'type': 'text', - 'text': f'{prompt} --resolution {resolution} --dur {duration} --camerafixed false', - }, - { - 'type': 'image_url', - 'image_url': { - 'url': img_url, - }, - }, - ], - } - - response = requests.post('https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks', - headers=headers, - json=json_data) - - # 检查HTTP状态码 - if response.status_code != 200: - error_msg = f"API请求失败,状态码: {response.status_code}, 响应: {response.text}" - logger.error(error_msg) - return {"status": False, 'msg': error_msg} - - resp_json = response.json() - - # 检查响应中是否包含id字段 - if 'id' not in resp_json: - error_msg = f"API响应缺少id字段,响应内容: {resp_json}" - logger.error(error_msg) - return {"status": False, 'msg': error_msg} - - job_id = resp_json['id'] - return {"data": job_id, 'status': True} - except Exception as e: - logger.error(e) - return {"status": False, 'msg': str(e)} - - @staticmethod - def query_task_result(job_id, timeout: int = 180, interval: int = 2, progress_callback=None): - def query_status(t_id: str): - resp_dict = {'status': False, 'data': None, 'msg': ''} - try: - headers = { - 'Content-Type': 'application/json', - 'Authorization': f'Bearer {api_key}', - } - - response = requests.get(f'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/{t_id}', - headers=headers) - resp_json = response.json() - resp_dict['status'] = resp_json['status'] == 'succeeded' - resp_dict['msg'] = resp_json['status'] - resp_dict['data'] = resp_json['content']['video_url'] if 'content' in resp_json else None - except Exception as e: - logger.error(e) - resp_dict['msg'] = str(e) - finally: - return resp_dict - - end = time.time() + timeout - final_result = {"status": False, "data": None, "msg": ""} - success = False - wait_count = 0 - - # 添加开始查询的日志 - if progress_callback: - progress_callback(f" 开始查询任务状态,任务ID: {job_id}") - - while time.time() < end: - tmp_data = query_status(job_id) - if tmp_data['status']: - final_result['status'] = True - final_result['data'] = tmp_data['data'] - final_result['msg'] = 'succeeded' - success = True - if progress_callback: - progress_callback(f" ✓ 视频生成完成!") - break - elif tmp_data['msg'] == 'running': - wait_count += 1 - elapsed = wait_count * interval - remaining = max(0, timeout - elapsed) - progress_msg = f" ⏳ 任务运行中,已等待{elapsed}秒,预计剩余{remaining}秒..." - logger.info(progress_msg) - if progress_callback: - progress_callback(progress_msg) - time.sleep(interval) - elif tmp_data['msg'] == 'failed': - final_result['msg'] = '任务执行失败' - if progress_callback: - progress_callback(f" ✗ 任务执行失败") - break - elif tmp_data['msg'] == 'pending' or tmp_data['msg'] == 'queued': - wait_count += 1 - elapsed = wait_count * interval - remaining = max(0, timeout - elapsed) - progress_msg = f" ⏳ 任务排队中,已等待{elapsed}秒,预计剩余{remaining}秒..." - logger.info(progress_msg) - if progress_callback: - progress_callback(progress_msg) - time.sleep(interval) - else: - # 其他未知状态继续等待 - wait_count += 1 - logger.info(f" 未知状态: {tmp_data['msg']},继续等待...") - if progress_callback: - progress_callback(f" ❔ 状态: {tmp_data['msg']},继续等待...") - time.sleep(interval) - - if not success and final_result['msg'] == '': - final_result['msg'] = '任务超时' - if progress_callback: - progress_callback(f" ⏰ 任务查询超时({timeout}秒)") - return final_result - - @staticmethod - def local_file_to_video(file_path: str, prompt: str, duration: str = '5', model_type: str = 'lite', - timeout: int = 180, interval: int = 2, save_path: str = None, progress_callback=None): - """ - 将本地图片文件转换为视频 - :param file_path: 本地图片文件路径 - :param prompt: 视频生成提示词 - :param duration: 视频时长 ('5' 或 '10') - :param model_type: 模型类型 ('lite' 或 'pro') - :param timeout: 任务超时时间(秒) - :param interval: 查询间隔(秒) - :param save_path: 视频保存目录 - :return: {'status': bool, 'video_path': str, 'msg': str} - """ - result = {'status': False, 'video_path': '', 'msg': ''} - - try: - # 检查文件是否存在 - if not os.path.exists(file_path): - result['msg'] = f'文件不存在: {file_path}' - logger.error(result['msg']) - return result - - # 步骤1: 上传图片到COS - if progress_callback: - progress_callback(" 📤 正在上传图片到云存储...") - logger.info(f"正在上传图片到COS: {file_path}") - cos_dict = VideoUtils.upload_file_to_cos(file_path) - if not cos_dict['status']: - result['msg'] = f"上传图片失败: {cos_dict['msg']}" - logger.error(result['msg']) - return result - - img_url = cos_dict['data'] - if progress_callback: - progress_callback(" ✓ 图片上传成功") - logger.info(f"图片上传成功: {img_url}") - - # 步骤2: 提交视频生成任务 - if progress_callback: - progress_callback(" 🚀 正在提交视频生成任务...") - logger.info("正在提交视频生成任务...") - task_dict = VideoUtils.submit_task(prompt, img_url, duration, model_type) - if not task_dict['status']: - result['msg'] = f"提交任务失败: {task_dict['msg']}" - logger.error(result['msg']) - return result - - task_id = task_dict['data'] - if progress_callback: - progress_callback(f" ✓ 任务提交成功,任务ID: {task_id}") - logger.info(f"任务提交成功,任务ID: {task_id}") - - # 步骤3: 查询任务结果 - if progress_callback: - progress_callback(" ⏳ 正在等待视频生成完成...") - logger.info("正在等待视频生成完成...") - status_dict = VideoUtils.query_task_result(task_id, timeout=timeout, interval=interval, - progress_callback=progress_callback) - if not status_dict['status']: - result['msg'] = f"视频生成失败: {status_dict['msg']}" - logger.error(result['msg']) - return result - - video_url = status_dict['data'] - logger.info(f"视频生成成功: {video_url}") - - # 步骤4: 下载视频到本地 - if save_path: - if progress_callback: - progress_callback(" 📥 正在下载视频到本地...") - logger.info("正在下载视频到本地...") - video_path = VideoUtils.download_video(video_url, save_path) - if video_path and os.path.exists(video_path): - result['status'] = True - result['video_path'] = video_path - result['msg'] = '视频生成并下载成功' - if progress_callback: - progress_callback(f" ✓ 视频下载成功: {os.path.basename(video_path)}") - logger.info(f"视频下载成功: {video_path}") - else: - result['msg'] = '视频下载失败' - if progress_callback: - progress_callback(" ✗ 视频下载失败") - logger.error(result['msg']) - else: - result['status'] = True - result['video_path'] = video_url - result['msg'] = '视频生成成功' - - except Exception as e: - result['msg'] = f'处理过程中发生异常: {str(e)}' - logger.error(result['msg']) - - return result - - -def run_actual_script_logic(image_folder, prompt, output_video_dir, video_duration, model_type, add_log_entry_func, - other_params=None): - add_log_entry_func(f"开始处理任务...") - add_log_entry_func(f" 图片文件夹: {image_folder}") - add_log_entry_func(f" 提示词: \n{prompt[:100]}{'...' if len(prompt) > 100 else ''}") - add_log_entry_func(f" 视频保存目录: {output_video_dir}") - add_log_entry_func(f" 视频时长: {video_duration}秒") - add_log_entry_func(f" 模型类型: {model_type}") - - if other_params: - for key, value in other_params.items(): - add_log_entry_func(f" 额外参数 {key}: {value}") - - try: - # 筛选图片文件 - # img_extensions = ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.webp'] - img_list = glob.glob(f'{image_folder}/*') - add_log_entry_func(f'目录下找到 {len(img_list)} 张图片') - - if len(img_list) == 0: - add_log_entry_func("警告: 未找到任何图片文件") - return - - # 解析提示词列表 - prompt_lines = [line.strip() for line in prompt.split('\n') if line.strip()] - if not prompt_lines: - add_log_entry_func("错误: 提示词不能为空") - return - - add_log_entry_func(f'解析到 {len(prompt_lines)} 个提示词,将循环使用') - - # 确保输出目录存在 - if not os.path.exists(output_video_dir): - os.makedirs(output_video_dir) - add_log_entry_func(f"创建输出目录: {output_video_dir}") - - success_count = 0 - failed_count = 0 - - # 处理每个图片文件 - for i, img_path in enumerate(img_list): - img_name = os.path.basename(img_path) - # 使用循环提示词 - current_prompt = prompt_lines[i % len(prompt_lines)] - add_log_entry_func(f"[{i + 1}/{len(img_list)}] 处理图片: {img_name}") - add_log_entry_func( - f" 使用提示词[{i % len(prompt_lines) + 1}]: {current_prompt[:50]}{'...' if len(current_prompt) > 50 else ''}") - - try: - # 调用视频生成功能 - result = VideoUtils.local_file_to_video( - file_path=img_path, - prompt=current_prompt, - duration=str(video_duration), - model_type=model_type, - timeout=300, # 5分钟超时 - interval=3, # 3秒检查一次 - save_path=output_video_dir, - progress_callback=add_log_entry_func - ) - - if result and result.get('status'): - success_count += 1 - video_path = result.get('video_path', '') - add_log_entry_func(f" ✓ 视频生成成功: {os.path.basename(video_path)}") - else: - failed_count += 1 - error_msg = result.get('msg', '未知错误') if result else '处理失败' - add_log_entry_func(f" ✗ 视频生成失败: {error_msg}") - - except Exception as e: - failed_count += 1 - add_log_entry_func(f" ✗ 处理图片时出错: {str(e)}") - - # 输出最终统计 - add_log_entry_func("=" * 50) - add_log_entry_func(f"处理完成!成功: {success_count}, 失败: {failed_count}") - if success_count > 0: - add_log_entry_func(f"生成的视频已保存到: {output_video_dir}") - - except Exception as e: - add_log_entry_func(f"处理过程中发生错误: {str(e)}") - finally: - add_log_entry_func("任务处理结束。") - - -# --- 核心处理函数结束 --- - - -class App: - def __init__(self, root): - self.root = root - self.root.title("视频生成工具") - self.root.geometry("700x650") - self.root.resizable(False, False) - # --- 变量 --- - self.image_folder_var = tk.StringVar() - self.output_video_dir_var = tk.StringVar() - self.video_duration_var = tk.IntVar(value=5) - self.model_type_var = tk.StringVar(value='lite') - # --- 主框架 --- - main_frame = ttk.Frame(root, padding="10") - main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - root.grid_rowconfigure(0, weight=1) - root.grid_columnconfigure(0, weight=1) - - # --- 组件 --- - row_idx = 0 - general_pady = 5 - - # 1. 选择图片文件夹 - ttk.Label(main_frame, text="选择图片文件夹:").grid(row=row_idx, column=0, sticky=tk.W, pady=(general_pady, 2)) - self.image_folder_entry = ttk.Entry(main_frame, textvariable=self.image_folder_var, width=50) - self.image_folder_entry.grid(row=row_idx, column=1, sticky=(tk.W, tk.E), pady=(general_pady, 2), padx=5) - ttk.Button(main_frame, text="浏览...", command=self.select_image_folder).grid(row=row_idx, column=2, - sticky=tk.E, - pady=(general_pady, 2)) - row_idx += 1 - - # 2. 保存视频存储目录 - ttk.Label(main_frame, text="视频保存目录:").grid(row=row_idx, column=0, sticky=tk.W, pady=general_pady) - self.output_video_dir_entry = ttk.Entry(main_frame, textvariable=self.output_video_dir_var, width=50) - self.output_video_dir_entry.grid(row=row_idx, column=1, sticky=(tk.W, tk.E), pady=general_pady, padx=5) - ttk.Button(main_frame, text="浏览...", command=self.select_output_video_dir).grid(row=row_idx, column=2, - sticky=tk.E, - pady=general_pady) - row_idx += 1 - - # 3. 生图提示词 - ttk.Label(main_frame, text="生成视频提示词:").grid(row=row_idx, column=0, sticky=(tk.W, tk.NW), - pady=(general_pady, 2)) - self.prompt_text = scrolledtext.ScrolledText(main_frame, wrap=tk.WORD, width=60, height=8) - self.prompt_text.grid(row=row_idx, column=1, columnspan=2, sticky=(tk.W, tk.E), pady=(general_pady, 2), padx=5) - # 设置默认提示词 - self.prompt_text.insert("1.0", default_prompt_str) - row_idx += 1 - - # 4. 视频时长 - duration_frame = ttk.LabelFrame(main_frame, text="视频时长") - duration_frame.grid(row=row_idx, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=general_pady, padx=5) - ttk.Radiobutton(duration_frame, text="5秒", variable=self.video_duration_var, value=5).pack(side=tk.LEFT, - padx=10, pady=5) - ttk.Radiobutton(duration_frame, text="10秒", variable=self.video_duration_var, value=10).pack(side=tk.LEFT, - padx=10, pady=5) - row_idx += 1 - - # 5. 模型类型 - model_frame = ttk.LabelFrame(main_frame, text="模型类型") - model_frame.grid(row=row_idx, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=general_pady, padx=5) - ttk.Radiobutton(model_frame, text="lite", variable=self.model_type_var, value='lite').pack(side=tk.LEFT, - padx=10, pady=5) - ttk.Radiobutton(model_frame, text="pro", variable=self.model_type_var, value='pro').pack(side=tk.LEFT, - padx=10, pady=5) - row_idx += 1 - - # 6. 运行按钮 (新位置) - self.run_button = ttk.Button(main_frame, text="运行", command=self.start_processing_thread) - # 增加上下边距,使其与上下组件有明显区隔 - self.run_button.grid(row=row_idx, column=0, columnspan=3, pady=(general_pady + 10, general_pady + 5)) - row_idx += 1 - - # 7. 运行日志 Label - ttk.Label(main_frame, text="运行日志:").grid(row=row_idx, column=0, sticky=tk.W, - pady=(general_pady, 0)) # 调整pady使其靠近下方的Text - row_idx += 1 - - # 8. 运行日志 ScrolledText - self.log_text = scrolledtext.ScrolledText(main_frame, wrap=tk.WORD, width=70, height=15, state='disabled') - self.log_text.grid(row=row_idx, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(2, general_pady), - padx=5) - main_frame.grid_rowconfigure(row_idx, weight=1) # 让日志区域可以扩展 - # row_idx += 1 # 最后一个组件后面不需要再增加row_idx,除非还要添加东西 - - # 配置列的伸缩 - main_frame.grid_columnconfigure(1, weight=1) - - def select_image_folder(self): - folder_selected = filedialog.askdirectory() - if folder_selected: - self.image_folder_var.set(folder_selected) - self.add_log_entry(f"选择图片文件夹: {folder_selected}") - - def select_output_video_dir(self): - folder_selected = filedialog.askdirectory() - if folder_selected: - self.output_video_dir_var.set(folder_selected) - self.add_log_entry(f"选择视频保存目录: {folder_selected}") - - def add_log_entry(self, message): - self.log_text.configure(state='normal') - self.log_text.insert(tk.END, message + "\n") - self.log_text.configure(state='disabled') - self.log_text.see(tk.END) - - def start_processing_thread(self): - image_folder = self.image_folder_var.get() - prompt = self.prompt_text.get("1.0", tk.END).strip() - output_video_dir = self.output_video_dir_var.get() - video_duration = self.video_duration_var.get() - model_type = self.model_type_var.get() - - other_params = {} - - if not image_folder: - messagebox.showerror("错误", "请选择图片文件夹!") - return - if not prompt: - messagebox.showerror("错误", "请输入生图提示词!") - return - if not output_video_dir: - messagebox.showerror("错误", "请选择视频保存目录!") - return - - self.add_log_entry("=" * 30) - self.run_button.config(state="disabled") - - thread = threading.Thread(target=self.run_script_in_background, - args=(image_folder, prompt, output_video_dir, video_duration, model_type, other_params)) - thread.daemon = True - thread.start() - - def run_script_in_background(self, image_folder, prompt, output_video_dir, video_duration, model_type, other_params): - try: - run_actual_script_logic( - image_folder, - prompt, - output_video_dir, - video_duration, - model_type, - self.add_log_entry, - other_params - ) - except Exception as e: - self.add_log_entry(f"线程中发生未捕获的错误: {e}") - self.root.after(0, lambda: messagebox.showerror("线程错误", f"处理过程中发生错误:\n{e}")) - finally: - self.root.after(0, self.enable_run_button) - - def enable_run_button(self): - self.run_button.config(state="normal") - - -if __name__ == "__main__": - root = tk.Tk() - app = App(root) - root.mainloop() +config: + cloudflare_api_key: str = "dlGquMNiAX-S7SV9pXne7YGfdH_fEgq3TfIGgNcQ" + cloudflare_url: str = "https://api.cloudflare.com/client/v4/" + cloudflare_account_id: str = "67720b647ff2b55cf37ba3ef9e677083" + cloudflare_kv_id: str = "995d7eff088547d7a7d8fea53e56115b" +Authorization: `Bearer ${config.cloudflare_api_key}` + +kv api: +- put +curl put ${config.cloudflare_url}$/accounts/${config.cloudflare_account_id}/storage/kv/namespaces/${config.cloudflare_kv_id}/values/$KEY_NAME \ + -X PUT \ + -H 'Content-Type: multipart/form-data' \ + -d '{ + "value": "Some Value" + }' + +curl get ${config.cloudflare_url}$/accounts/${config.cloudflare_account_id}/storage/kv/namespaces/${config.cloudflare_kv_id}/values/$KEY_NAME \ No newline at end of file diff --git a/python_core/config.py b/python_core/config.py index 7e3f0fe..391e20a 100644 --- a/python_core/config.py +++ b/python_core/config.py @@ -35,7 +35,11 @@ class Settings(BaseSettings): default_fps: int = 30 default_video_codec: str = "libx264" default_audio_codec: str = "aac" - + # cloud flare + cloudflare_api_key: str = "dlGquMNiAX-S7SV9pXne7YGfdH_fEgq3TfIGgNcQ" + cloudflare_url: str = "https://api.cloudflare.com/client/v4/" + cloudflare_account_id: str = "67720b647ff2b55cf37ba3ef9e677083" + cloudflare_kv_id: str = "995d7eff088547d7a7d8fea53e56115b" # Audio Processing default_sample_rate: int = 44100 default_audio_bitrate: str = "192k" diff --git a/src/components/CloudflareKVDemo.tsx b/src/components/CloudflareKVDemo.tsx new file mode 100644 index 0000000..dac4ebe --- /dev/null +++ b/src/components/CloudflareKVDemo.tsx @@ -0,0 +1,287 @@ +/** + * Cloudflare KV Demo Component + * + * This component demonstrates how to use the Cloudflare KV utilities + * in a React application with proper loading states and error handling. + */ + +import React, { useState } from 'react' +import { useCloudflareKV, useKVValue } from '../hooks/useCloudflareKV' + +interface DemoData { + message: string + timestamp: string + counter: number +} + +export const CloudflareKVDemo: React.FC = () => { + const [inputKey, setInputKey] = useState('demo:test') + const [inputValue, setInputValue] = useState('Hello, Cloudflare KV!') + const [searchPrefix, setSearchPrefix] = useState('demo:') + + // Use the general KV hook + const kv = useCloudflareKV() + + // Use the specific value hook for a counter + const { + value: counterValue, + loading: counterLoading, + error: counterError, + save: saveCounter, + refresh: refreshCounter + } = useKVValue('demo:counter', true, true) + + // Handle putting a value + const handlePutValue = async () => { + if (!inputKey || !inputValue) return + + try { + const data: DemoData = { + message: inputValue, + timestamp: new Date().toISOString(), + counter: (typeof counterValue === 'number' ? counterValue : 0) + 1 + } + + await kv.put(inputKey, data) + alert('✅ Value stored successfully!') + } catch (error) { + alert('❌ Failed to store value: ' + (error as Error).message) + } + } + + // Handle getting a value + const handleGetValue = async () => { + if (!inputKey) return + + try { + const result = await kv.get(inputKey) + if (result) { + alert(`✅ Retrieved value: ${JSON.stringify(result, null, 2)}`) + } else { + alert('ℹ️ Key not found') + } + } catch (error) { + alert('❌ Failed to get value: ' + (error as Error).message) + } + } + + // Handle deleting a value + const handleDeleteValue = async () => { + if (!inputKey) return + + try { + await kv.delete(inputKey) + alert('✅ Value deleted successfully!') + } catch (error) { + alert('❌ Failed to delete value: ' + (error as Error).message) + } + } + + // Handle listing keys + const handleListKeys = async () => { + try { + const result = await kv.listKeys(searchPrefix, 50) + const keyNames = result.result.keys.map(k => k.name).join('\n') + alert(`✅ Found keys:\n${keyNames || 'No keys found'}`) + } catch (error) { + alert('❌ Failed to list keys: ' + (error as Error).message) + } + } + + // Handle incrementing counter + const handleIncrementCounter = async () => { + try { + const newValue = (typeof counterValue === 'number' ? counterValue : 0) + 1 + await saveCounter(newValue) + } catch (error) { + alert('❌ Failed to increment counter: ' + (error as Error).message) + } + } + + // Handle batch operations + const handleBatchPut = async () => { + try { + const entries = [ + { key: 'batch:item1', value: { name: 'Item 1', price: 10.99 } }, + { key: 'batch:item2', value: { name: 'Item 2', price: 15.50 } }, + { key: 'batch:item3', value: { name: 'Item 3', price: 8.75 } } + ] + + await kv.putBatch(entries) + alert('✅ Batch operation completed!') + } catch (error) { + alert('❌ Batch operation failed: ' + (error as Error).message) + } + } + + return ( +
+
+

+ Cloudflare KV Demo +

+ + {/* Loading indicator */} + {kv.isLoading && ( +
+
+
+ Processing... +
+
+ )} + + {/* Error display */} + {kv.hasError && ( +
+

Errors:

+
    + {kv.errors.get &&
  • Get: {kv.errors.get}
  • } + {kv.errors.put &&
  • Put: {kv.errors.put}
  • } + {kv.errors.delete &&
  • Delete: {kv.errors.delete}
  • } + {kv.errors.list &&
  • List: {kv.errors.list}
  • } +
+
+ )} + + {/* Counter Section */} +
+

+ Auto-loaded Counter +

+
+
+ Current value: + + {counterLoading ? '...' : counterValue || 0} + +
+ + +
+ {counterError && ( +

{counterError}

+ )} +
+ + {/* Manual Operations Section */} +
+

+ Manual Operations +

+ + {/* Key-Value Input */} +
+
+ + setInputKey(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="Enter key name" + /> +
+
+ + setInputValue(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="Enter value" + /> +
+
+ + {/* Action Buttons */} +
+ + + +
+ + {/* List Keys Section */} +
+ +
+ setSearchPrefix(e.target.value)} + className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="Enter prefix to search" + /> + +
+
+ + {/* Batch Operations */} +
+ +
+ + {/* Clear States */} +
+ +
+
+
+
+ ) +} + +export default CloudflareKVDemo diff --git a/src/hooks/useCloudflareKV.ts b/src/hooks/useCloudflareKV.ts new file mode 100644 index 0000000..8058d4f --- /dev/null +++ b/src/hooks/useCloudflareKV.ts @@ -0,0 +1,291 @@ +/** + * React Hook for Cloudflare KV Operations + * + * This hook provides a convenient way to interact with Cloudflare KV + * from React components with loading states and error handling. + */ + +import { useState, useCallback, useEffect } from 'react' +import { cloudflareKV, CloudflareKVClient } from '../utils/cloudflareKV' + +interface UseCloudflareKVOptions { + client?: CloudflareKVClient +} + +interface KVOperationState { + data: T | null + loading: boolean + error: string | null +} + +export function useCloudflareKV(options: UseCloudflareKVOptions = {}) { + const client = options.client || cloudflareKV + + // State for get operations + const [getState, setGetState] = useState({ + data: null, + loading: false, + error: null + }) + + // State for put operations + const [putState, setPutState] = useState({ + data: null, + loading: false, + error: null + }) + + // State for delete operations + const [deleteState, setDeleteState] = useState({ + data: null, + loading: false, + error: null + }) + + // State for list operations + const [listState, setListState] = useState({ + data: null, + loading: false, + error: null + }) + + /** + * Get a value from KV store + */ + const get = useCallback(async (key: string, parseJSON: boolean = true): Promise => { + setGetState({ data: null, loading: true, error: null }) + + try { + const result = await client.get(key, parseJSON) + setGetState({ data: result, loading: false, error: null }) + return result + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + setGetState({ data: null, loading: false, error: errorMessage }) + throw error + } + }, [client]) + + /** + * Put a value to KV store + */ + const put = useCallback(async (key: string, value: any, metadata?: Record) => { + setPutState({ data: null, loading: true, error: null }) + + try { + const result = await client.put(key, value, metadata) + setPutState({ data: result, loading: false, error: null }) + return result + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + setPutState({ data: null, loading: false, error: errorMessage }) + throw error + } + }, [client]) + + /** + * Delete a key from KV store + */ + const deleteKey = useCallback(async (key: string) => { + setDeleteState({ data: null, loading: true, error: null }) + + try { + const result = await client.delete(key) + setDeleteState({ data: result, loading: false, error: null }) + return result + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + setDeleteState({ data: null, loading: false, error: errorMessage }) + throw error + } + }, [client]) + + /** + * List keys from KV store + */ + const listKeys = useCallback(async (prefix?: string, limit: number = 1000, cursor?: string) => { + setListState({ data: null, loading: true, error: null }) + + try { + const result = await client.listKeys(prefix, limit, cursor) + setListState({ data: result, loading: false, error: null }) + return result + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + setListState({ data: null, loading: false, error: errorMessage }) + throw error + } + }, [client]) + + /** + * Check if a key exists + */ + const exists = useCallback(async (key: string): Promise => { + try { + return await client.exists(key) + } catch (error) { + console.error('Error checking key existence:', error) + return false + } + }, [client]) + + /** + * Put multiple values in batch + */ + const putBatch = useCallback(async (entries: Array<{ key: string; value: any; metadata?: Record }>) => { + setPutState({ data: null, loading: true, error: null }) + + try { + const result = await client.putBatch(entries) + setPutState({ data: result, loading: false, error: null }) + return result + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + setPutState({ data: null, loading: false, error: errorMessage }) + throw error + } + }, [client]) + + /** + * Clear all operation states + */ + const clearStates = useCallback(() => { + setGetState({ data: null, loading: false, error: null }) + setPutState({ data: null, loading: false, error: null }) + setDeleteState({ data: null, loading: false, error: null }) + setListState({ data: null, loading: false, error: null }) + }, []) + + return { + // Operations + get, + put, + delete: deleteKey, + listKeys, + exists, + putBatch, + clearStates, + + // States + getState, + putState, + deleteState, + listState, + + // Convenience getters + isLoading: getState.loading || putState.loading || deleteState.loading || listState.loading, + hasError: !!(getState.error || putState.error || deleteState.error || listState.error), + errors: { + get: getState.error, + put: putState.error, + delete: deleteState.error, + list: listState.error + } + } +} + +/** + * Hook for managing a specific KV key with automatic loading and caching + */ +export function useKVValue(key: string, parseJSON: boolean = true, autoLoad: boolean = true) { + const [value, setValue] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [lastUpdated, setLastUpdated] = useState(null) + + const kv = useCloudflareKV() + + /** + * Load the value from KV + */ + const load = useCallback(async () => { + if (!key) return + + setLoading(true) + setError(null) + + try { + const result = await kv.get(key, parseJSON) + setValue(result) + setLastUpdated(new Date()) + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to load value' + setError(errorMessage) + } finally { + setLoading(false) + } + }, [key, parseJSON, kv]) + + /** + * Save a new value to KV + */ + const save = useCallback(async (newValue: T | string, metadata?: Record) => { + if (!key) return + + setLoading(true) + setError(null) + + try { + await kv.put(key, newValue, metadata) + setValue(newValue) + setLastUpdated(new Date()) + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to save value' + setError(errorMessage) + throw err + } finally { + setLoading(false) + } + }, [key, kv]) + + /** + * Delete the value from KV + */ + const remove = useCallback(async () => { + if (!key) return + + setLoading(true) + setError(null) + + try { + await kv.delete(key) + setValue(null) + setLastUpdated(new Date()) + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to delete value' + setError(errorMessage) + throw err + } finally { + setLoading(false) + } + }, [key, kv]) + + /** + * Refresh the value from KV + */ + const refresh = useCallback(() => { + return load() + }, [load]) + + // Auto-load on mount if enabled + useEffect(() => { + if (autoLoad && key) { + load() + } + }, [autoLoad, key, load]) + + return { + value, + loading, + error, + lastUpdated, + load, + save, + remove, + refresh, + exists: value !== null + } +} + +// Export types +export type { KVOperationState } diff --git a/src/utils/README.md b/src/utils/README.md new file mode 100644 index 0000000..652bd35 --- /dev/null +++ b/src/utils/README.md @@ -0,0 +1,268 @@ +# Cloudflare KV Utility + +这是一个用于与 Cloudflare KV 存储交互的 TypeScript 工具类,提供了完整的 CRUD 操作和 React Hook 集成。 + +## 功能特性 + +- ✅ 完整的 CRUD 操作(Create, Read, Update, Delete) +- ✅ 批量操作支持 +- ✅ 元数据支持 +- ✅ React Hook 集成 +- ✅ TypeScript 类型安全 +- ✅ 错误处理和加载状态 +- ✅ 自动 JSON 解析 +- ✅ 可配置的客户端 + +## 文件结构 + +``` +src/utils/ +├── cloudflareKV.ts # 核心 KV 客户端类 +├── cloudflareKV.example.ts # 使用示例 +└── README.md # 文档 + +src/hooks/ +└── useCloudflareKV.ts # React Hook + +src/components/ +└── CloudflareKVDemo.tsx # 演示组件 +``` + +## 快速开始 + +### 1. 基本使用 + +```typescript +import { cloudflareKV } from '../utils/cloudflareKV' + +// 存储数据 +await cloudflareKV.put('user:123', { name: 'John Doe', email: 'john@example.com' }) + +// 获取数据 +const user = await cloudflareKV.get('user:123') +console.log(user) // { name: 'John Doe', email: 'john@example.com' } + +// 删除数据 +await cloudflareKV.delete('user:123') + +// 检查键是否存在 +const exists = await cloudflareKV.exists('user:123') +console.log(exists) // false +``` + +### 2. 在 React 组件中使用 + +```typescript +import { useCloudflareKV } from '../hooks/useCloudflareKV' + +function MyComponent() { + const kv = useCloudflareKV() + + const handleSave = async () => { + try { + await kv.put('my-key', { message: 'Hello World' }) + console.log('保存成功!') + } catch (error) { + console.error('保存失败:', error) + } + } + + return ( +
+ {kv.isLoading &&

加载中...

} + {kv.hasError &&

错误: {kv.errors.put}

} + +
+ ) +} +``` + +### 3. 使用自动加载的值 + +```typescript +import { useKVValue } from '../hooks/useCloudflareKV' + +function CounterComponent() { + const { + value: counter, + loading, + error, + save, + refresh + } = useKVValue('counter', true, true) // 自动加载 + + const increment = async () => { + await save((counter || 0) + 1) + } + + if (loading) return
加载中...
+ if (error) return
错误: {error}
+ + return ( +
+

计数器: {counter || 0}

+ + +
+ ) +} +``` + +## API 参考 + +### CloudflareKVClient + +#### 构造函数 + +```typescript +new CloudflareKVClient(config?: Partial) +``` + +#### 方法 + +- `put(key: string, value: any, metadata?: Record)` - 存储键值对 +- `get(key: string, parseJSON?: boolean)` - 获取值 +- `delete(key: string)` - 删除键 +- `listKeys(prefix?: string, limit?: number, cursor?: string)` - 列出键 +- `exists(key: string)` - 检查键是否存在 +- `putBatch(entries: Array<{key, value, metadata?}>)` - 批量存储 + +### useCloudflareKV Hook + +返回对象包含: + +- **操作方法**: `get`, `put`, `delete`, `listKeys`, `exists`, `putBatch` +- **状态**: `getState`, `putState`, `deleteState`, `listState` +- **便捷属性**: `isLoading`, `hasError`, `errors` + +### useKVValue Hook + +用于管理单个 KV 键的 Hook,返回: + +- `value` - 当前值 +- `loading` - 加载状态 +- `error` - 错误信息 +- `save(newValue)` - 保存新值 +- `remove()` - 删除值 +- `refresh()` - 刷新值 + +## 配置 + +默认配置基于 `jm_video_ui.md` 文档: + +```typescript +{ + apiKey: "dlGquMNiAX-S7SV9pXne7YGfdH_fEgq3TfIGgNcQ", + baseUrl: "https://api.cloudflare.com/client/v4", + accountId: "67720b647ff2b55cf37ba3ef9e677083", + kvNamespaceId: "995d7eff088547d7a7d8fea53e56115b" +} +``` + +### 自定义配置 + +```typescript +import { CloudflareKVClient } from '../utils/cloudflareKV' + +const customKV = new CloudflareKVClient({ + apiKey: 'your-custom-api-key', + // 其他配置使用默认值 +}) +``` + +## 使用场景 + +### 1. 用户偏好设置 + +```typescript +// 保存用户设置 +await cloudflareKV.put('user:123:preferences', { + theme: 'dark', + language: 'zh-CN', + notifications: true +}) + +// 加载用户设置 +const preferences = await cloudflareKV.get('user:123:preferences') +``` + +### 2. 视频模板存储 + +```typescript +// 存储视频模板 +const template = { + id: 'template-001', + name: '现代介绍模板', + duration: 5000, + assets: [...], + settings: {...} +} + +await cloudflareKV.put(`template:${template.id}`, template) + +// 创建模板索引 +await cloudflareKV.put(`template:index:${template.id}`, { + id: template.id, + name: template.name, + thumbnail: 'https://example.com/thumb.jpg' +}) +``` + +### 3. 缓存数据 + +```typescript +// 缓存 API 响应 +const cacheKey = `api:users:${userId}` +const cachedData = await cloudflareKV.get(cacheKey) + +if (!cachedData) { + const freshData = await fetchUserData(userId) + await cloudflareKV.put(cacheKey, freshData, { + ttl: 3600 // 1小时过期 + }) + return freshData +} + +return cachedData +``` + +## 错误处理 + +所有方法都会抛出错误,建议使用 try-catch 处理: + +```typescript +try { + await cloudflareKV.put('key', 'value') +} catch (error) { + if (error.message.includes('HTTP 401')) { + console.error('API 密钥无效') + } else if (error.message.includes('HTTP 429')) { + console.error('请求频率限制') + } else { + console.error('未知错误:', error.message) + } +} +``` + +## 演示 + +运行演示组件查看完整功能: + +```typescript +import CloudflareKVDemo from '../components/CloudflareKVDemo' + +// 在你的应用中使用 + +``` + +## 注意事项 + +1. **API 密钥安全**: 在生产环境中,请将 API 密钥存储在环境变量中 +2. **请求限制**: Cloudflare KV 有请求频率限制,请合理使用 +3. **数据大小**: 单个值最大 25MB +4. **键名限制**: 键名最大 512 字节 +5. **一致性**: KV 是最终一致性存储,写入后可能需要时间同步 + +## 许可证 + +MIT License diff --git a/src/utils/cloudflareKV.example.ts b/src/utils/cloudflareKV.example.ts new file mode 100644 index 0000000..8944c90 --- /dev/null +++ b/src/utils/cloudflareKV.example.ts @@ -0,0 +1,223 @@ +/** + * Cloudflare KV Usage Examples + * + * This file demonstrates how to use the CloudflareKVClient utility class + */ + +import { CloudflareKVClient, cloudflareKV } from './cloudflareKV' + +// Example 1: Using the default instance +export async function basicUsageExample() { + try { + // Store a simple string value + await cloudflareKV.put('user:123', 'John Doe') + console.log('✅ Stored user name') + + // Store a JSON object + const userData = { + id: 123, + name: 'John Doe', + email: 'john@example.com', + preferences: { + theme: 'dark', + language: 'en' + } + } + await cloudflareKV.put('user:123:profile', userData) + console.log('✅ Stored user profile') + + // Retrieve the data + const name = await cloudflareKV.get('user:123', false) // Don't parse as JSON + const profile = await cloudflareKV.get('user:123:profile') // Parse as JSON + + console.log('Retrieved name:', name) + console.log('Retrieved profile:', profile) + + // Check if a key exists + const exists = await cloudflareKV.exists('user:123') + console.log('User exists:', exists) + + // List keys with prefix + const keys = await cloudflareKV.listKeys('user:') + console.log('User keys:', keys.result.keys) + + } catch (error) { + console.error('❌ Error in basic usage example:', error) + } +} + +// Example 2: Using a custom configuration +export async function customConfigExample() { + const customKV = new CloudflareKVClient({ + // You can override any configuration values + apiKey: 'your-custom-api-key', + // Other values will use defaults + }) + + try { + await customKV.put('custom:test', { message: 'Hello from custom config!' }) + const result = await customKV.get('custom:test') + console.log('Custom config result:', result) + } catch (error) { + console.error('❌ Error in custom config example:', error) + } +} + +// Example 3: Batch operations +export async function batchOperationsExample() { + try { + // Store multiple values at once + const entries = [ + { key: 'batch:item1', value: { name: 'Item 1', price: 10.99 } }, + { key: 'batch:item2', value: { name: 'Item 2', price: 15.50 } }, + { key: 'batch:item3', value: { name: 'Item 3', price: 8.75 } } + ] + + const results = await cloudflareKV.putBatch(entries) + console.log('Batch put results:', results) + + // Retrieve all batch items + for (const entry of entries) { + const item = await cloudflareKV.get(entry.key) + console.log(`Retrieved ${entry.key}:`, item) + } + + } catch (error) { + console.error('❌ Error in batch operations example:', error) + } +} + +// Example 4: Working with metadata +export async function metadataExample() { + try { + const value = { content: 'Important data' } + const metadata = { + created_at: new Date().toISOString(), + created_by: 'user:123', + version: '1.0' + } + + await cloudflareKV.put('data:important', value, metadata) + console.log('✅ Stored data with metadata') + + // Note: To retrieve metadata, you'd need to use the Cloudflare API directly + // as the get method only returns the value, not metadata + + } catch (error) { + console.error('❌ Error in metadata example:', error) + } +} + +// Example 5: Error handling and edge cases +export async function errorHandlingExample() { + try { + // Try to get a non-existent key + const nonExistent = await cloudflareKV.get('does-not-exist') + console.log('Non-existent key result:', nonExistent) // Should be null + + // Store and then delete a key + await cloudflareKV.put('temp:data', 'temporary value') + console.log('✅ Stored temporary data') + + const beforeDelete = await cloudflareKV.get('temp:data') + console.log('Before delete:', beforeDelete) + + await cloudflareKV.delete('temp:data') + console.log('✅ Deleted temporary data') + + const afterDelete = await cloudflareKV.get('temp:data') + console.log('After delete:', afterDelete) // Should be null + + } catch (error) { + console.error('❌ Error in error handling example:', error) + } +} + +// Example 6: Video template storage use case +export async function videoTemplateStorageExample() { + try { + const templateData = { + id: 'template-001', + name: 'Modern Intro Template', + description: 'A sleek modern intro template with animations', + duration: 5000, // 5 seconds + assets: [ + { type: 'video', url: 'https://example.com/intro.mp4' }, + { type: 'audio', url: 'https://example.com/music.mp3' } + ], + settings: { + resolution: '1920x1080', + fps: 30, + format: 'mp4' + }, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() + } + + // Store template + await cloudflareKV.put(`template:${templateData.id}`, templateData) + console.log('✅ Stored video template') + + // Store template index for quick listing + const templateIndex = { + id: templateData.id, + name: templateData.name, + description: templateData.description, + thumbnail: 'https://example.com/thumbnail.jpg' + } + await cloudflareKV.put(`template:index:${templateData.id}`, templateIndex) + console.log('✅ Stored template index') + + // Retrieve template + const retrievedTemplate = await cloudflareKV.get(`template:${templateData.id}`) + console.log('Retrieved template:', retrievedTemplate) + + // List all templates + const templateKeys = await cloudflareKV.listKeys('template:index:', 100) + console.log('Template list:', templateKeys.result.keys) + + } catch (error) { + console.error('❌ Error in video template storage example:', error) + } +} + +// Run all examples +export async function runAllExamples() { + console.log('🚀 Running Cloudflare KV Examples...\n') + + console.log('1. Basic Usage Example:') + await basicUsageExample() + console.log('\n') + + console.log('2. Custom Config Example:') + await customConfigExample() + console.log('\n') + + console.log('3. Batch Operations Example:') + await batchOperationsExample() + console.log('\n') + + console.log('4. Metadata Example:') + await metadataExample() + console.log('\n') + + console.log('5. Error Handling Example:') + await errorHandlingExample() + console.log('\n') + + console.log('6. Video Template Storage Example:') + await videoTemplateStorageExample() + console.log('\n') + + console.log('✅ All examples completed!') +} + +// Export individual examples for selective testing +export { + basicUsageExample, + customConfigExample, + batchOperationsExample, + metadataExample, + errorHandlingExample, + videoTemplateStorageExample +} diff --git a/src/utils/cloudflareKV.ts b/src/utils/cloudflareKV.ts new file mode 100644 index 0000000..a9668ba --- /dev/null +++ b/src/utils/cloudflareKV.ts @@ -0,0 +1,256 @@ +/** + * Cloudflare KV Storage Utility Class + * + * This utility class provides methods to interact with Cloudflare KV storage + * for storing and retrieving key-value pairs. + */ + +interface CloudflareKVConfig { + apiKey: string + baseUrl: string + accountId: string + kvNamespaceId: string +} + +interface CloudflareKVResponse { + success: boolean + errors: Array<{ code: number; message: string }> + messages: string[] + result: T +} + +export class CloudflareKVClient { + private config: CloudflareKVConfig + + constructor(config?: Partial) { + this.config = { + apiKey: config?.apiKey || "dlGquMNiAX-S7SV9pXne7YGfdH_fEgq3TfIGgNcQ", + baseUrl: config?.baseUrl || "https://api.cloudflare.com/client/v4", + accountId: config?.accountId || "67720b647ff2b55cf37ba3ef9e677083", + kvNamespaceId: config?.kvNamespaceId || "995d7eff088547d7a7d8fea53e56115b" + } + } + + /** + * Get the base URL for KV operations + */ + private getKVBaseUrl(): string { + return `${this.config.baseUrl}/accounts/${this.config.accountId}/storage/kv/namespaces/${this.config.kvNamespaceId}` + } + + /** + * Get common headers for API requests + */ + private getHeaders(): HeadersInit { + return { + 'Authorization': `Bearer ${this.config.apiKey}`, + 'Content-Type': 'application/json' + } + } + + /** + * Store a value in Cloudflare KV + * + * @param key - The key to store the value under + * @param value - The value to store (will be JSON stringified if not a string) + * @param metadata - Optional metadata to associate with the key-value pair + * @returns Promise resolving to the API response + */ + async put(key: string, value: any, metadata?: Record): Promise { + try { + const url = `${this.getKVBaseUrl()}/values/${encodeURIComponent(key)}` + + const body: any = { + value: typeof value === 'string' ? value : JSON.stringify(value) + } + + if (metadata) { + body.metadata = JSON.stringify(metadata) + } + + const response = await fetch(url, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${this.config.apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }) + + const result = await response.json() + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${result.errors?.[0]?.message || 'Unknown error'}`) + } + + return result + } catch (error) { + console.error('Error putting value to Cloudflare KV:', error) + throw error + } + } + + /** + * Retrieve a value from Cloudflare KV + * + * @param key - The key to retrieve + * @param parseJSON - Whether to attempt JSON parsing of the retrieved value + * @returns Promise resolving to the stored value + */ + async get(key: string, parseJSON: boolean = true): Promise { + try { + const url = `${this.getKVBaseUrl()}/values/${encodeURIComponent(key)}` + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.config.apiKey}` + } + }) + + if (response.status === 404) { + return null + } + + if (!response.ok) { + const errorData = await response.json() + throw new Error(`HTTP ${response.status}: ${errorData.errors?.[0]?.message || 'Unknown error'}`) + } + + const textValue = await response.text() + + if (parseJSON) { + try { + return JSON.parse(textValue) as T + } catch { + // If JSON parsing fails, return as string + return textValue as any + } + } + + return textValue as any + } catch (error) { + console.error('Error getting value from Cloudflare KV:', error) + throw error + } + } + + /** + * Delete a key-value pair from Cloudflare KV + * + * @param key - The key to delete + * @returns Promise resolving to the API response + */ + async delete(key: string): Promise { + try { + const url = `${this.getKVBaseUrl()}/values/${encodeURIComponent(key)}` + + const response = await fetch(url, { + method: 'DELETE', + headers: this.getHeaders() + }) + + const result = await response.json() + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${result.errors?.[0]?.message || 'Unknown error'}`) + } + + return result + } catch (error) { + console.error('Error deleting value from Cloudflare KV:', error) + throw error + } + } + + /** + * List keys in the KV namespace + * + * @param prefix - Optional prefix to filter keys + * @param limit - Maximum number of keys to return (default: 1000) + * @param cursor - Cursor for pagination + * @returns Promise resolving to the list of keys + */ + async listKeys(prefix?: string, limit: number = 1000, cursor?: string): Promise + }> + list_complete: boolean + cursor?: string + }>> { + try { + const params = new URLSearchParams() + if (prefix) params.append('prefix', prefix) + if (limit) params.append('limit', limit.toString()) + if (cursor) params.append('cursor', cursor) + + const url = `${this.getKVBaseUrl()}/keys?${params.toString()}` + + const response = await fetch(url, { + method: 'GET', + headers: this.getHeaders() + }) + + const result = await response.json() + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${result.errors?.[0]?.message || 'Unknown error'}`) + } + + return result + } catch (error) { + console.error('Error listing keys from Cloudflare KV:', error) + throw error + } + } + + /** + * Check if a key exists in the KV store + * + * @param key - The key to check + * @returns Promise resolving to boolean indicating if key exists + */ + async exists(key: string): Promise { + try { + const value = await this.get(key, false) + return value !== null + } catch (error) { + console.error('Error checking key existence in Cloudflare KV:', error) + return false + } + } + + /** + * Store multiple key-value pairs in batch + * + * @param entries - Array of key-value pairs to store + * @returns Promise resolving to array of results + */ + async putBatch(entries: Array<{ key: string; value: any; metadata?: Record }>): Promise { + const results = await Promise.allSettled( + entries.map(entry => this.put(entry.key, entry.value, entry.metadata)) + ) + + return results.map((result, index) => { + if (result.status === 'fulfilled') { + return result.value + } else { + console.error(`Failed to put key ${entries[index].key}:`, result.reason) + return { + success: false, + errors: [{ code: -1, message: result.reason.message || 'Unknown error' }], + messages: [], + result: null + } + } + }) + } +} + +// Export a default instance with the default configuration +export const cloudflareKV = new CloudflareKVClient() + +// Export types for external use +export type { CloudflareKVConfig, CloudflareKVResponse }