cloudflare下排除无用nodejs包

This commit is contained in:
mtvpls
2026-06-11 11:57:29 +08:00
parent 25b068f673
commit 99bcc8ed77
11 changed files with 264 additions and 151 deletions
+28 -7
View File
@@ -2,7 +2,7 @@
/* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-var-requires */
const { PHASE_DEVELOPMENT_SERVER } = require('next/constants'); const { PHASE_DEVELOPMENT_SERVER } = require('next/constants');
const webpack = require('webpack'); const path = require('path');
// 检测是否为 Cloudflare Pages 构建 // 检测是否为 Cloudflare Pages 构建
const isCloudflare = process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare'; const isCloudflare = process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare';
@@ -91,14 +91,35 @@ const createNextConfig = (phase) => {
// Cloudflare 使用 D1,不需要把 better-sqlite3 原生模块带入 Worker 产物。 // Cloudflare 使用 D1,不需要把 better-sqlite3 原生模块带入 Worker 产物。
if (isCloudflare) { if (isCloudflare) {
config.plugins.push(
new webpack.IgnorePlugin({
resourceRegExp: /^better-sqlite3$/,
})
);
config.resolve.alias = { config.resolve.alias = {
...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) => { config.externals = (config.externals || []).filter((external) => {
return !( return !(
+1 -1
View File
@@ -5,7 +5,7 @@
"scripts": { "scripts": {
"dev": "pnpm gen:manifest && node server.js", "dev": "pnpm gen:manifest && node server.js",
"build": "pnpm gen:manifest && next build", "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", "start": "NODE_ENV=production node server.js",
"preview:cloudflare": "wrangler dev", "preview:cloudflare": "wrangler dev",
"deploy:cloudflare": "wrangler deploy", "deploy:cloudflare": "wrangler deploy",
@@ -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');
}
}
+12
View File
@@ -0,0 +1,12 @@
/* Cloudflare build shim: Workers provide standards-based fetch globally. */
export default function nodeFetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
return fetch(input, init);
}
export const Headers = globalThis.Headers;
export const Request = globalThis.Request;
export const Response = globalThis.Response;
@@ -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;
+15 -3
View File
@@ -2,7 +2,6 @@
import { AdminConfig } from './admin.types'; import { AdminConfig } from './admin.types';
import { MusicPlayRecord } from './db.client'; import { MusicPlayRecord } from './db.client';
import { KvrocksStorage } from './kvrocks.db';
import { MangaReadRecord, MangaShelfItem } from './manga.types'; import { MangaReadRecord, MangaShelfItem } from './manga.types';
import { BookReadRecord, BookShelfItem } from './book.types'; import { BookReadRecord, BookShelfItem } from './book.types';
import { import {
@@ -10,7 +9,6 @@ import {
MusicV2PlaylistItem, MusicV2PlaylistItem,
MusicV2PlaylistRecord, MusicV2PlaylistRecord,
} from './music-v2'; } from './music-v2';
import { RedisStorage } from './redis.db';
import { import {
DanmakuFilterConfig, DanmakuFilterConfig,
Favorite, Favorite,
@@ -18,9 +16,10 @@ import {
PlayRecord, PlayRecord,
SkipConfig, SkipConfig,
} from './types'; } from './types';
import { UpstashRedisStorage } from './upstash.db';
// storage type 常量: 'localstorage' | 'redis' | 'upstash' | 'kvrocks' | 'd1' | 'postgres',默认 'localstorage' // 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 = const STORAGE_TYPE =
(process.env.NEXT_PUBLIC_STORAGE_TYPE as (process.env.NEXT_PUBLIC_STORAGE_TYPE as
| 'localstorage' | 'localstorage'
@@ -35,10 +34,23 @@ const STORAGE_TYPE =
function createStorage(): IStorage { function createStorage(): IStorage {
switch (STORAGE_TYPE) { switch (STORAGE_TYPE) {
case 'redis': 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(); return new RedisStorage();
case 'upstash': case 'upstash':
const { UpstashRedisStorage } = require('./upstash.db');
return new UpstashRedisStorage(); return new UpstashRedisStorage();
case 'kvrocks': 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(); return new KvrocksStorage();
case 'd1': case 'd1':
// D1Storage 只能在服务端使用,客户端会报错 // D1Storage 只能在服务端使用,客户端会报错
+2 -1
View File
@@ -1,7 +1,8 @@
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ /* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
import { StandardRedisAdapter } from './redis-adapter'; 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 { export class KvrocksStorage extends BaseRedisStorage {
constructor() { constructor() {
+1 -1
View File
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { Redis } from '@upstash/redis'; import { Redis } from '@upstash/redis';
import { RedisClientType } from 'redis'; import type { RedisClientType } from 'redis';
/** /**
* 统一的 Redis 适配器接口 * 统一的 Redis 适配器接口
-137
View File
@@ -1,7 +1,5 @@
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ /* 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 { AdminConfig } from './admin.types';
import { MangaReadRecord, MangaShelfItem } from './manga.types'; import { MangaReadRecord, MangaShelfItem } from './manga.types';
import { BookReadRecord, BookShelfItem } from './book.types'; import { BookReadRecord, BookShelfItem } from './book.types';
@@ -30,141 +28,6 @@ function ensureStringArray(value: any[]): string[] {
// 内存锁:用于防止同一用户的并发播放记录操作(迁移、清理等) // 内存锁:用于防止同一用户的并发播放记录操作(迁移、清理等)
const playRecordLocks = new Map<string, Promise<void>>(); const playRecordLocks = new Map<string, Promise<void>>();
// 连接配置接口
export interface RedisConnectionConfig {
url: string;
clientName: string; // 用于日志显示,如 "Redis" 或 "Pika"
}
// 添加Redis操作重试包装器
export function createRetryWrapper(
clientName: string,
getClient: () => RedisClientType
) {
return async function withRetry<T>(
operation: () => Promise<T>,
maxRetries = 3
): Promise<T> {
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操作逻辑 // 抽象基类,包含所有通用的Redis操作逻辑
export abstract class BaseRedisStorage implements IStorage { export abstract class BaseRedisStorage implements IStorage {
protected adapter: RedisAdapter; protected adapter: RedisAdapter;
+139
View File
@@ -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<T>(
operation: () => Promise<T>,
maxRetries = 3
): Promise<T> {
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;
}
+2 -1
View File
@@ -1,7 +1,8 @@
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ /* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
import { StandardRedisAdapter } from './redis-adapter'; 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 { export class RedisStorage extends BaseRedisStorage {
constructor() { constructor() {