Astro กับ Upstash Redis: Caching และ Rate Limiting

#astro13 เม.ย. 2569

Astro กับ Upstash Redis: Caching และ Rate Limiting

Upstash เป็น serverless Redis ที่ออกแบบมาสำหรับ edge environments ด้วย HTTP-based API ทำให้ทำงานได้บน Cloudflare Workers, Vercel Edge, และ Deno Deploy ได้อย่างสมบูรณ์ บทความนี้จะแนะนำการใช้ Upstash Redis สำหรับ caching และ rate limiting ใน Astro

ทำไมต้องใช้ Upstash Redis?

  • Serverless: ไม่ต้องจัดการ server
  • Edge Compatible: ทำงานได้บน edge runtimes
  • HTTP API: ไม่ต้องใช้ TCP connection
  • Pay-per-use: จ่ายตามการใช้งานจริง
  • Global Replication: ข้อมูลอยู่ใกล้ผู้ใช้

การติดตั้ง

npm install @upstash/redis @upstash/ratelimit

ตั้งค่า environment variables:

# .env
UPSTASH_REDIS_REST_URL=https://your-redis.upstash.io
UPSTASH_REDIS_REST_TOKEN=your-token

สร้าง Redis Client

// src/lib/redis.ts
import { Redis } from '@upstash/redis';

export const redis = new Redis({
  url: import.meta.env.UPSTASH_REDIS_REST_URL,
  token: import.meta.env.UPSTASH_REDIS_REST_TOKEN,
});

// Helper functions
export async function getCache<T>(key: string): Promise<T | null> {
  return redis.get<T>(key);
}

export async function setCache<T>(
  key: string,
  value: T,
  ttlSeconds = 3600
): Promise<void> {
  await redis.setex(key, ttlSeconds, value);
}

export async function deleteCache(key: string): Promise<void> {
  await redis.del(key);
}

export async function deleteCachePattern(pattern: string): Promise<void> {
  const keys = await redis.keys(pattern);
  if (keys.length > 0) {
    await redis.del(...keys);
  }
}

Caching API Responses

// src/pages/api/posts.ts
import type { APIRoute } from 'astro';
import { getCache, setCache } from '../../lib/redis';
import { getAllPosts } from '../../lib/posts';

export const GET: APIRoute = async ({ url }) => {
  const page = url.searchParams.get('page') ?? '1';
  const cacheKey = `posts:page:${page}`;

  // ลองดึงจาก cache ก่อน
  const cached = await getCache(cacheKey);
  if (cached) {
    return new Response(JSON.stringify(cached), {
      headers: {
        'Content-Type': 'application/json',
        'X-Cache': 'HIT',
      },
    });
  }

  // ถ้าไม่มีใน cache ดึงจาก database
  const data = await getAllPosts(parseInt(page));

  // เก็บใน cache 5 นาที
  await setCache(cacheKey, data, 300);

  return new Response(JSON.stringify(data), {
    headers: {
      'Content-Type': 'application/json',
      'X-Cache': 'MISS',
    },
  });
};

Rate Limiting

// src/lib/rateLimit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { redis } from './redis';

// 10 requests per 10 seconds
export const apiRateLimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '10 s'),
  analytics: true,
  prefix: 'api',
});

// 5 requests per minute for auth
export const authRateLimit = new Ratelimit({
  redis,
  limiter: Ratelimit.fixedWindow(5, '60 s'),
  analytics: true,
  prefix: 'auth',
});

// 100 requests per hour for general
export const generalRateLimit = new Ratelimit({
  redis,
  limiter: Ratelimit.tokenBucket(100, '1 h', 10),
  analytics: true,
  prefix: 'general',
});

Middleware สำหรับ Rate Limiting

// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
import { apiRateLimit, authRateLimit } from './lib/rateLimit';

export const onRequest = defineMiddleware(async (context, next) => {
  const { pathname } = context.url;
  const ip =
    context.request.headers.get('x-forwarded-for') ??
    context.request.headers.get('x-real-ip') ??
    '127.0.0.1';

  // Rate limit สำหรับ auth endpoints
  if (pathname.startsWith('/api/auth')) {
    const { success, limit, remaining, reset } = await authRateLimit.limit(ip);

    if (!success) {
      return new Response(
        JSON.stringify({
          error: 'Too many requests',
          retryAfter: Math.ceil((reset - Date.now()) / 1000),
        }),
        {
          status: 429,
          headers: {
            'Content-Type': 'application/json',
            'X-RateLimit-Limit': limit.toString(),
            'X-RateLimit-Remaining': remaining.toString(),
            'X-RateLimit-Reset': reset.toString(),
            'Retry-After': Math.ceil((reset - Date.now()) / 1000).toString(),
          },
        }
      );
    }
  }

  // Rate limit สำหรับ API endpoints
  if (pathname.startsWith('/api/')) {
    const { success, remaining } = await apiRateLimit.limit(ip);

    if (!success) {
      return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), {
        status: 429,
        headers: { 'Content-Type': 'application/json' },
      });
    }

    const response = await next();
    response.headers.set('X-RateLimit-Remaining', remaining.toString());
    return response;
  }

  return next();
});

Session Storage

// src/lib/session.ts
import { redis } from './redis';
import { nanoid } from 'nanoid';

interface Session {
  userId: string;
  email: string;
  role: string;
  createdAt: number;
}

export async function createSession(data: Omit<Session, 'createdAt'>): Promise<string> {
  const sessionId = nanoid(32);
  const session: Session = { ...data, createdAt: Date.now() };

  await redis.setex(
    `session:${sessionId}`,
    7 * 24 * 60 * 60, // 7 days
    JSON.stringify(session)
  );

  return sessionId;
}

export async function getSession(sessionId: string): Promise<Session | null> {
  const data = await redis.get<string>(`session:${sessionId}`);
  if (!data) return null;
  return JSON.parse(data);
}

export async function deleteSession(sessionId: string): Promise<void> {
  await redis.del(`session:${sessionId}`);
}

Pub/Sub สำหรับ Real-time Features

// src/lib/pubsub.ts
import { redis } from './redis';

export async function publishEvent(channel: string, data: unknown) {
  await redis.publish(channel, JSON.stringify(data));
}

export async function incrementCounter(key: string): Promise<number> {
  return redis.incr(key);
}

export async function getLeaderboard(
  key: string,
  limit = 10
): Promise<{ member: string; score: number }[]> {
  const results = await redis.zrange(key, 0, limit - 1, {
    rev: true,
    withScores: true,
  });

  const leaderboard = [];
  for (let i = 0; i < results.length; i += 2) {
    leaderboard.push({
      member: results[i] as string,
      score: Number(results[i + 1]),
    });
  }
  return leaderboard;
}

สรุป

Upstash Redis กับ Astro เป็นคู่ที่ยอดเยี่ยมสำหรับ serverless applications ด้วย HTTP-based API ที่ทำงานได้บน edge environments, caching ที่ช่วยลด database load, และ rate limiting ที่ป้องกัน abuse ทำให้ application มีทั้ง performance และ security ที่ดี