From 99bcc8ed7799b4a561447c4ee56ae239e58bef07 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Thu, 11 Jun 2026 11:57:29 +0800 Subject: [PATCH] =?UTF-8?q?cloudflare=E4=B8=8B=E6=8E=92=E9=99=A4=E6=97=A0?= =?UTF-8?q?=E7=94=A8nodejs=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.js | 35 ++++- package.json | 2 +- src/lib/cloudflare-shims/https-proxy-agent.ts | 7 + src/lib/cloudflare-shims/node-fetch.ts | 12 ++ src/lib/cloudflare-shims/node-unsupported.ts | 57 +++++++ src/lib/db.ts | 18 ++- src/lib/kvrocks.db.ts | 3 +- src/lib/redis-adapter.ts | 2 +- src/lib/redis-base.db.ts | 137 ----------------- src/lib/redis-node-client.ts | 139 ++++++++++++++++++ src/lib/redis.db.ts | 3 +- 11 files changed, 264 insertions(+), 151 deletions(-) create mode 100644 src/lib/cloudflare-shims/https-proxy-agent.ts create mode 100644 src/lib/cloudflare-shims/node-fetch.ts create mode 100644 src/lib/cloudflare-shims/node-unsupported.ts create mode 100644 src/lib/redis-node-client.ts diff --git a/next.config.js b/next.config.js index b297b64..11e06f6 100644 --- a/next.config.js +++ b/next.config.js @@ -2,7 +2,7 @@ /* eslint-disable @typescript-eslint/no-var-requires */ const { PHASE_DEVELOPMENT_SERVER } = require('next/constants'); -const webpack = require('webpack'); +const path = require('path'); // 检测是否为 Cloudflare Pages 构建 const isCloudflare = process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare'; @@ -91,14 +91,35 @@ const createNextConfig = (phase) => { // Cloudflare 使用 D1,不需要把 better-sqlite3 原生模块带入 Worker 产物。 if (isCloudflare) { - config.plugins.push( - new webpack.IgnorePlugin({ - resourceRegExp: /^better-sqlite3$/, - }) - ); config.resolve.alias = { ...config.resolve.alias, - 'better-sqlite3': false, + ...Object.fromEntries( + [ + 'better-sqlite3', + 'sharp', + 'nodemailer', + 'socket.io', + 'redis', + '@vercel/postgres', + 'pg', + ].map((pkg) => [ + pkg, + path.resolve( + __dirname, + 'src/lib/cloudflare-shims/node-unsupported.ts' + ), + ]) + ), + // Cloudflare Workers 有原生 fetch;代理 Agent 在 Workers 中不可用。 + // 用轻量 shim 替换 node-fetch / https-proxy-agent,避免把 Node HTTP 栈打入 Worker。 + 'node-fetch': path.resolve( + __dirname, + 'src/lib/cloudflare-shims/node-fetch.ts' + ), + 'https-proxy-agent': path.resolve( + __dirname, + 'src/lib/cloudflare-shims/https-proxy-agent.ts' + ), }; config.externals = (config.externals || []).filter((external) => { return !( diff --git a/package.json b/package.json index 0f24a22..a8594c5 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "pnpm gen:manifest && node server.js", "build": "pnpm gen:manifest && next build", - "build:cloudflare": "BUILD_TARGET=cloudflare pnpm gen:manifest && npx @opennextjs/cloudflare build", + "build:cloudflare": "BUILD_TARGET=cloudflare pnpm gen:manifest && BUILD_TARGET=cloudflare npx @opennextjs/cloudflare build", "start": "NODE_ENV=production node server.js", "preview:cloudflare": "wrangler dev", "deploy:cloudflare": "wrangler deploy", diff --git a/src/lib/cloudflare-shims/https-proxy-agent.ts b/src/lib/cloudflare-shims/https-proxy-agent.ts new file mode 100644 index 0000000..ab24e52 --- /dev/null +++ b/src/lib/cloudflare-shims/https-proxy-agent.ts @@ -0,0 +1,7 @@ +/* Cloudflare build shim: outbound proxy agents are not supported by Workers. */ + +export class HttpsProxyAgent { + constructor(..._args: unknown[]) { + throw new Error('HttpsProxyAgent is not supported in Cloudflare Workers'); + } +} diff --git a/src/lib/cloudflare-shims/node-fetch.ts b/src/lib/cloudflare-shims/node-fetch.ts new file mode 100644 index 0000000..c56c5d8 --- /dev/null +++ b/src/lib/cloudflare-shims/node-fetch.ts @@ -0,0 +1,12 @@ +/* Cloudflare build shim: Workers provide standards-based fetch globally. */ + +export default function nodeFetch( + input: RequestInfo | URL, + init?: RequestInit +): Promise { + return fetch(input, init); +} + +export const Headers = globalThis.Headers; +export const Request = globalThis.Request; +export const Response = globalThis.Response; diff --git a/src/lib/cloudflare-shims/node-unsupported.ts b/src/lib/cloudflare-shims/node-unsupported.ts new file mode 100644 index 0000000..ef10710 --- /dev/null +++ b/src/lib/cloudflare-shims/node-unsupported.ts @@ -0,0 +1,57 @@ +/* Cloudflare build shim for Node-only packages. + * + * This keeps unsupported Node dependencies out of the Worker bundle while + * failing loudly if a Node-only code path is actually executed on Workers. + */ + +function unsupported(name = 'This Node-only package'): never { + throw new Error(`${name} is not supported in Cloudflare Workers`); +} + +export function createClient(): never { + return unsupported('redis'); +} + +export class Server { + constructor() { + unsupported('socket.io'); + } +} + +export class Socket { + constructor() { + unsupported('socket.io'); + } +} + +export class Pool { + constructor() { + unsupported('pg'); + } +} + +export const sql = new Proxy(() => undefined, { + apply: () => unsupported('@vercel/postgres'), + get: () => unsupported('@vercel/postgres'), +}); + +const shim: any = new Proxy( + function nodeUnsupportedDefault() { + unsupported(); + }, + { + apply: () => unsupported(), + construct: () => unsupported(), + get: (_target, prop) => { + if (prop === 'createTransport') { + return () => unsupported('nodemailer'); + } + if (prop === 'default') { + return shim; + } + return () => unsupported(String(prop)); + }, + } +); + +export default shim; diff --git a/src/lib/db.ts b/src/lib/db.ts index fa4c0aa..914b945 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -2,7 +2,6 @@ import { AdminConfig } from './admin.types'; import { MusicPlayRecord } from './db.client'; -import { KvrocksStorage } from './kvrocks.db'; import { MangaReadRecord, MangaShelfItem } from './manga.types'; import { BookReadRecord, BookShelfItem } from './book.types'; import { @@ -10,7 +9,6 @@ import { MusicV2PlaylistItem, MusicV2PlaylistRecord, } from './music-v2'; -import { RedisStorage } from './redis.db'; import { DanmakuFilterConfig, Favorite, @@ -18,9 +16,10 @@ import { PlayRecord, SkipConfig, } from './types'; -import { UpstashRedisStorage } from './upstash.db'; // storage type 常量: 'localstorage' | 'redis' | 'upstash' | 'kvrocks' | 'd1' | 'postgres',默认 'localstorage' +const IS_CLOUDFLARE_BUILD = + process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare'; const STORAGE_TYPE = (process.env.NEXT_PUBLIC_STORAGE_TYPE as | 'localstorage' @@ -35,10 +34,23 @@ const STORAGE_TYPE = function createStorage(): IStorage { switch (STORAGE_TYPE) { case 'redis': + if (IS_CLOUDFLARE_BUILD) { + throw new Error( + 'Node Redis storage is not supported in Cloudflare builds. Use D1 or Upstash instead.' + ); + } + const { RedisStorage } = require('./redis.db'); return new RedisStorage(); case 'upstash': + const { UpstashRedisStorage } = require('./upstash.db'); return new UpstashRedisStorage(); case 'kvrocks': + if (IS_CLOUDFLARE_BUILD) { + throw new Error( + 'Kvrocks storage is not supported in Cloudflare builds. Use D1 or Upstash instead.' + ); + } + const { KvrocksStorage } = require('./kvrocks.db'); return new KvrocksStorage(); case 'd1': // D1Storage 只能在服务端使用,客户端会报错 diff --git a/src/lib/kvrocks.db.ts b/src/lib/kvrocks.db.ts index 29acd4e..d9fba9e 100644 --- a/src/lib/kvrocks.db.ts +++ b/src/lib/kvrocks.db.ts @@ -1,7 +1,8 @@ /* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ import { StandardRedisAdapter } from './redis-adapter'; -import { BaseRedisStorage, createRedisClient, createRetryWrapper } from './redis-base.db'; +import { BaseRedisStorage } from './redis-base.db'; +import { createRedisClient, createRetryWrapper } from './redis-node-client'; export class KvrocksStorage extends BaseRedisStorage { constructor() { diff --git a/src/lib/redis-adapter.ts b/src/lib/redis-adapter.ts index 50ccdb8..460520f 100644 --- a/src/lib/redis-adapter.ts +++ b/src/lib/redis-adapter.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Redis } from '@upstash/redis'; -import { RedisClientType } from 'redis'; +import type { RedisClientType } from 'redis'; /** * 统一的 Redis 适配器接口 diff --git a/src/lib/redis-base.db.ts b/src/lib/redis-base.db.ts index e123048..89c952c 100644 --- a/src/lib/redis-base.db.ts +++ b/src/lib/redis-base.db.ts @@ -1,7 +1,5 @@ /* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ -import { createClient, RedisClientType } from 'redis'; - import { AdminConfig } from './admin.types'; import { MangaReadRecord, MangaShelfItem } from './manga.types'; import { BookReadRecord, BookShelfItem } from './book.types'; @@ -30,141 +28,6 @@ function ensureStringArray(value: any[]): string[] { // 内存锁:用于防止同一用户的并发播放记录操作(迁移、清理等) const playRecordLocks = new Map>(); -// 连接配置接口 -export interface RedisConnectionConfig { - url: string; - clientName: string; // 用于日志显示,如 "Redis" 或 "Pika" -} - -// 添加Redis操作重试包装器 -export function createRetryWrapper( - clientName: string, - getClient: () => RedisClientType -) { - return async function withRetry( - operation: () => Promise, - maxRetries = 3 - ): Promise { - for (let i = 0; i < maxRetries; i++) { - try { - return await operation(); - } catch (err: any) { - const isLastAttempt = i === maxRetries - 1; - const isConnectionError = - err.message?.includes('Connection') || - err.message?.includes('ECONNREFUSED') || - err.message?.includes('ENOTFOUND') || - err.code === 'ECONNRESET' || - err.code === 'EPIPE'; - - if (isConnectionError && !isLastAttempt) { - console.log( - `${clientName} operation failed, retrying... (${ - i + 1 - }/${maxRetries})` - ); - console.error('Error:', err.message); - - // 等待一段时间后重试 - await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1))); - - // 尝试重新连接 - try { - const client = getClient(); - if (!client.isOpen) { - await client.connect(); - } - } catch (reconnectErr) { - console.error('Failed to reconnect:', reconnectErr); - } - - continue; - } - - throw err; - } - } - - throw new Error('Max retries exceeded'); - }; -} - -// 创建客户端的工厂函数 -export function createRedisClient( - config: RedisConnectionConfig, - globalSymbol: symbol -): RedisClientType { - let client: RedisClientType | undefined = (global as any)[globalSymbol]; - - if (!client) { - if (!config.url) { - throw new Error(`${config.clientName}_URL env variable not set`); - } - - // 创建客户端配置 - const clientConfig: any = { - url: config.url, - socket: { - // 重连策略:指数退避,最大30秒 - reconnectStrategy: (retries: number) => { - console.log( - `${config.clientName} reconnection attempt ${retries + 1}` - ); - if (retries > 10) { - console.error( - `${config.clientName} max reconnection attempts exceeded` - ); - return false; // 停止重连 - } - return Math.min(1000 * Math.pow(2, retries), 30000); // 指数退避,最大30秒 - }, - connectTimeout: 10000, // 10秒连接超时 - // 设置no delay,减少延迟 - noDelay: true, - }, - // 添加其他配置 - pingInterval: 30000, // 30秒ping一次,保持连接活跃 - }; - - client = createClient(clientConfig); - - // 添加错误事件监听 - client.on('error', (err) => { - console.error(`${config.clientName} client error:`, err); - }); - - client.on('connect', () => { - console.log(`${config.clientName} connected`); - }); - - client.on('reconnecting', () => { - console.log(`${config.clientName} reconnecting...`); - }); - - client.on('ready', () => { - console.log(`${config.clientName} ready`); - }); - - // 初始连接,带重试机制 - const connectWithRetry = async () => { - try { - await client!.connect(); - console.log(`${config.clientName} connected successfully`); - } catch (err) { - console.error(`${config.clientName} initial connection failed:`, err); - console.log('Will retry in 5 seconds...'); - setTimeout(connectWithRetry, 5000); - } - }; - - connectWithRetry(); - - (global as any)[globalSymbol] = client; - } - - return client; -} - // 抽象基类,包含所有通用的Redis操作逻辑 export abstract class BaseRedisStorage implements IStorage { protected adapter: RedisAdapter; diff --git a/src/lib/redis-node-client.ts b/src/lib/redis-node-client.ts new file mode 100644 index 0000000..f84dfef --- /dev/null +++ b/src/lib/redis-node-client.ts @@ -0,0 +1,139 @@ +/* eslint-disable no-console, @typescript-eslint/no-explicit-any */ + +import { createClient, RedisClientType } from 'redis'; + +// 连接配置接口 +export interface RedisConnectionConfig { + url: string; + clientName: string; // 用于日志显示,如 "Redis" 或 "Pika" +} + +// 添加 Redis 操作重试包装器。这个文件只给 Node Redis/Kvrocks 使用, +// 避免 Cloudflare/D1/Upstash 构建因为 BaseRedisStorage 引入 node-redis。 +export function createRetryWrapper( + clientName: string, + getClient: () => RedisClientType +) { + return async function withRetry( + operation: () => Promise, + maxRetries = 3 + ): Promise { + for (let i = 0; i < maxRetries; i++) { + try { + return await operation(); + } catch (err: any) { + const isLastAttempt = i === maxRetries - 1; + const isConnectionError = + err.message?.includes('Connection') || + err.message?.includes('ECONNREFUSED') || + err.message?.includes('ENOTFOUND') || + err.code === 'ECONNRESET' || + err.code === 'EPIPE'; + + if (isConnectionError && !isLastAttempt) { + console.log( + `${clientName} operation failed, retrying... (${ + i + 1 + }/${maxRetries})` + ); + console.error('Error:', err.message); + + // 等待一段时间后重试 + await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1))); + + // 尝试重新连接 + try { + const client = getClient(); + if (!client.isOpen) { + await client.connect(); + } + } catch (reconnectErr) { + console.error('Failed to reconnect:', reconnectErr); + } + + continue; + } + + throw err; + } + } + + throw new Error('Max retries exceeded'); + }; +} + +// 创建客户端的工厂函数 +export function createRedisClient( + config: RedisConnectionConfig, + globalSymbol: symbol +): RedisClientType { + let client: RedisClientType | undefined = (global as any)[globalSymbol]; + + if (!client) { + if (!config.url) { + throw new Error(`${config.clientName}_URL env variable not set`); + } + + // 创建客户端配置 + const clientConfig: any = { + url: config.url, + socket: { + // 重连策略:指数退避,最大30秒 + reconnectStrategy: (retries: number) => { + console.log( + `${config.clientName} reconnection attempt ${retries + 1}` + ); + if (retries > 10) { + console.error( + `${config.clientName} max reconnection attempts exceeded` + ); + return false; // 停止重连 + } + return Math.min(1000 * Math.pow(2, retries), 30000); // 指数退避,最大30秒 + }, + connectTimeout: 10000, // 10秒连接超时 + // 设置 no delay,减少延迟 + noDelay: true, + }, + // 添加其他配置 + pingInterval: 30000, // 30秒 ping 一次,保持连接活跃 + }; + + client = createClient(clientConfig); + + // 添加错误事件监听 + client.on('error', (err) => { + console.error(`${config.clientName} client error:`, err); + }); + + client.on('connect', () => { + console.log(`${config.clientName} connected`); + }); + + client.on('reconnecting', () => { + console.log(`${config.clientName} reconnecting...`); + }); + + client.on('ready', () => { + console.log(`${config.clientName} ready`); + }); + + // 初始连接,带重试机制 + const connectWithRetry = async () => { + try { + await client!.connect(); + console.log(`${config.clientName} connected successfully`); + } catch (err) { + console.error(`${config.clientName} initial connection failed:`, err); + console.log('Will retry in 5 seconds...'); + setTimeout(connectWithRetry, 5000); + } + }; + + connectWithRetry(); + + (global as any)[globalSymbol] = client; + } + + return client; +} diff --git a/src/lib/redis.db.ts b/src/lib/redis.db.ts index 83fab7c..12210f3 100644 --- a/src/lib/redis.db.ts +++ b/src/lib/redis.db.ts @@ -1,7 +1,8 @@ /* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ import { StandardRedisAdapter } from './redis-adapter'; -import { BaseRedisStorage, createRedisClient, createRetryWrapper } from './redis-base.db'; +import { BaseRedisStorage } from './redis-base.db'; +import { createRedisClient, createRetryWrapper } from './redis-node-client'; export class RedisStorage extends BaseRedisStorage { constructor() {