Chapter 09: Advanced Features and Integration

Haiyue
25min
Learning Objectives
  • Implement Server-Side Rendering (SSR) mode
  • Integrate third-party services and APIs
  • Add PWA features and offline support
  • Implement internationalization (i18n) and multilingual support

Key Concepts

Astro Advanced Features Overview

Astro provides rich advanced features to meet complex application needs:

  • Hybrid Rendering: Combination of static generation and server rendering
  • Middleware System: Request handling and authentication
  • API Endpoints: Full-stack application development capabilities
  • Integration Ecosystem: Rich third-party integrations

Architecture Evolution Diagram

🔄 正在渲染 Mermaid 图表...

Server-Side Rendering (SSR)

SSR Configuration

// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import vue from '@astrojs/vue';

export default defineConfig({
  // Hybrid rendering mode
  output: 'hybrid',

  // Or fully SSR mode
  // output: 'server',

  adapter: node({
    mode: 'standalone'
  }),

  integrations: [vue()],

  // Pre-rendering configuration
  experimental: {
    hybridOutput: true,
  },
});

Hybrid Rendering Strategy

---
// src/pages/products/[id].astro

// Statically pre-render specific products
export const prerender = true;

export async function getStaticPaths() {
  // Only pre-render popular products
  const popularProducts = await fetch('/api/products/popular')
    .then(res => res.json());

  return popularProducts.map(product => ({
    params: { id: product.id.toString() },
    props: { product }
  }));
}

// For non-pre-rendered products, use SSR
const { id } = Astro.params;
let product = Astro.props.product;

if (!product) {
  // SSR path: dynamically fetch product data
  try {
    const response = await fetch(`${import.meta.env.API_BASE_URL}/products/${id}`);
    if (response.ok) {
      product = await response.json();
    } else {
      return Astro.redirect('/404');
    }
  } catch (error) {
    console.error('Failed to fetch product:', error);
    return Astro.redirect('/404');
  }
}
---

<html>
<head>
  <title>{product.name} - Product Details</title>
  <meta name="description" content={product.description} />

  <!-- SEO optimization -->
  <meta property="og:title" content={product.name} />
  <meta property="og:description" content={product.description} />
  <meta property="og:image" content={product.image} />
  <meta property="og:type" content="product" />

  <!-- Structured data -->
  <script type="application/ld+json" set:html={JSON.stringify({
    "@context": "https://schema.org/",
    "@type": "Product",
    "name": product.name,
    "description": product.description,
    "image": product.image,
    "offers": {
      "@type": "Offer",
      "price": product.price,
      "priceCurrency": "CNY"
    }
  })} />
</head>
<body>
  <main>
    <div class="product-detail">
      <img src={product.image} alt={product.name} />
      <div class="product-info">
        <h1>{product.name}</h1>
        <p class="price">¥{product.price}</p>
        <p class="description">{product.description}</p>

        <!-- Interactive component hydrates on client -->
        <AddToCartButton client:load product={product} />
      </div>
    </div>
  </main>
</body>
</html>

Middleware Implementation

// src/middleware/index.ts
import { defineMiddleware } from 'astro:middleware';
import { verifyJWT } from '../lib/auth';

export const onRequest = defineMiddleware(async (context, next) => {
  const { request, locals, redirect } = context;

  // Authentication middleware
  if (request.url.includes('/admin')) {
    const token = request.headers.get('authorization')?.replace('Bearer ', '');

    if (!token) {
      return redirect('/login');
    }

    try {
      const user = await verifyJWT(token);
      locals.user = user;
    } catch (error) {
      return redirect('/login');
    }
  }

  // Rate limiting middleware
  if (request.url.includes('/api')) {
    const clientIP = request.headers.get('x-forwarded-for') || 'unknown';
    const isRateLimited = await checkRateLimit(clientIP);

    if (isRateLimited) {
      return new Response('Rate limit exceeded', { status: 429 });
    }
  }

  // Security headers middleware
  const response = await next();

  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('X-XSS-Protection', '1; mode=block');

  return response;
});

// Rate limiting implementation
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();

async function checkRateLimit(clientIP: string): Promise<boolean> {
  const now = Date.now();
  const windowMs = 15 * 60 * 1000; // 15 minutes
  const maxRequests = 100;

  const current = rateLimitStore.get(clientIP);

  if (!current || now > current.resetTime) {
    rateLimitStore.set(clientIP, {
      count: 1,
      resetTime: now + windowMs
    });
    return false;
  }

  if (current.count >= maxRequests) {
    return true;
  }

  current.count++;
  return false;
}

API Endpoint Development

RESTful API Implementation

// src/pages/api/posts/[id].ts
import type { APIRoute } from 'astro';
import { z } from 'zod';

// Request validation schema
const UpdatePostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
  tags: z.array(z.string()).optional(),
  published: z.boolean().optional(),
});

export const GET: APIRoute = async ({ params, request }) => {
  const { id } = params;

  try {
    const post = await getPostById(id);

    if (!post) {
      return new Response(
        JSON.stringify({ error: 'Post not found' }),
        { status: 404, headers: { 'Content-Type': 'application/json' } }
      );
    }

    return new Response(JSON.stringify(post), {
      status: 200,
      headers: { 'Content-Type': 'application/json' }
    });
  } catch (error) {
    return new Response(
      JSON.stringify({ error: 'Internal server error' }),
      { status: 500, headers: { 'Content-Type': 'application/json' } }
    );
  }
};

export const PUT: APIRoute = async ({ params, request, locals }) => {
  const { id } = params;

  // Check authentication
  if (!locals.user) {
    return new Response(
      JSON.stringify({ error: 'Unauthorized' }),
      { status: 401, headers: { 'Content-Type': 'application/json' } }
    );
  }

  try {
    const body = await request.json();
    const validatedData = UpdatePostSchema.parse(body);

    const updatedPost = await updatePost(id, validatedData, locals.user.id);

    return new Response(JSON.stringify(updatedPost), {
      status: 200,
      headers: { 'Content-Type': 'application/json' }
    });
  } catch (error) {
    if (error instanceof z.ZodError) {
      return new Response(
        JSON.stringify({ error: 'Validation error', details: error.errors }),
        { status: 400, headers: { 'Content-Type': 'application/json' } }
      );
    }

    return new Response(
      JSON.stringify({ error: 'Internal server error' }),
      { status: 500, headers: { 'Content-Type': 'application/json' } }
    );
  }
};

export const DELETE: APIRoute = async ({ params, locals }) => {
  const { id } = params;

  if (!locals.user) {
    return new Response(
      JSON.stringify({ error: 'Unauthorized' }),
      { status: 401, headers: { 'Content-Type': 'application/json' } }
    );
  }

  try {
    await deletePost(id, locals.user.id);

    return new Response(null, { status: 204 });
  } catch (error) {
    return new Response(
      JSON.stringify({ error: 'Internal server error' }),
      { status: 500, headers: { 'Content-Type': 'application/json' } }
    );
  }
};

// Database operation functions
async function getPostById(id: string) {
  // Actual database query logic
  return null;
}

async function updatePost(id: string, data: any, userId: string) {
  // Actual update logic
  return null;
}

async function deletePost(id: string, userId: string) {
  // Actual delete logic
}

GraphQL Integration

// src/pages/api/graphql.ts
import type { APIRoute } from 'astro';
import { graphqlHTTP } from 'express-graphql';
import { buildSchema } from 'graphql';

// GraphQL Schema
const schema = buildSchema(`
  type Post {
    id: ID!
    title: String!
    content: String!
    author: User!
    createdAt: String!
    tags: [String!]!
  }

  type User {
    id: ID!
    name: String!
    email: String!
    posts: [Post!]!
  }

  type Query {
    posts: [Post!]!
    post(id: ID!): Post
    users: [User!]!
    user(id: ID!): User
  }

  type Mutation {
    createPost(title: String!, content: String!, tags: [String!]): Post!
    updatePost(id: ID!, title: String, content: String, tags: [String!]): Post!
    deletePost(id: ID!): Boolean!
  }
`);

// Resolvers
const rootValue = {
  posts: async () => {
    // Get all posts
    return await getAllPosts();
  },

  post: async ({ id }: { id: string }) => {
    return await getPostById(id);
  },

  createPost: async ({ title, content, tags }: { title: string; content: string; tags: string[] }) => {
    return await createPost({ title, content, tags });
  },

  updatePost: async ({ id, title, content, tags }: { id: string; title?: string; content?: string; tags?: string[] }) => {
    return await updatePost(id, { title, content, tags });
  },

  deletePost: async ({ id }: { id: string }) => {
    await deletePost(id);
    return true;
  },
};

const graphqlHandler = graphqlHTTP({
  schema,
  rootValue,
  graphiql: import.meta.env.DEV, // Enable GraphiQL in development
});

export const ALL: APIRoute = async ({ request }) => {
  return new Promise((resolve) => {
    graphqlHandler(request, {
      json: (data) => {
        resolve(new Response(JSON.stringify(data), {
          headers: { 'Content-Type': 'application/json' }
        }));
      }
    } as any, () => {});
  });
};

PWA Implementation

Service Worker Configuration

// public/sw.js
const CACHE_NAME = 'astro-pwa-v1';
const urlsToCache = [
  '/',
  '/offline',
  '/assets/css/main.css',
  '/assets/js/main.js',
  '/images/logo.png',
];

// Install event
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => {
        return cache.addAll(urlsToCache);
      })
  );
});

// Activate event
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheName !== CACHE_NAME) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

// Fetch interception
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => {
        // Cache hit, return cached resource
        if (response) {
          return response;
        }

        return fetch(event.request).then((response) => {
          // Check if response is valid
          if (!response || response.status !== 200 || response.type !== 'basic') {
            return response;
          }

          // Clone the response
          const responseToCache = response.clone();

          caches.open(CACHE_NAME)
            .then((cache) => {
              cache.put(event.request, responseToCache);
            });

          return response;
        }).catch(() => {
          // Network request failed, return offline page
          if (event.request.destination === 'document') {
            return caches.match('/offline');
          }
        });
      })
  );
});

// Background sync
self.addEventListener('sync', (event) => {
  if (event.tag === 'background-sync') {
    event.waitUntil(
      syncData()
    );
  }
});

async function syncData() {
  // Sync data saved while offline
  const offlineData = await getOfflineData();

  for (const data of offlineData) {
    try {
      await fetch('/api/sync', {
        method: 'POST',
        body: JSON.stringify(data),
        headers: { 'Content-Type': 'application/json' }
      });

      // Sync successful, delete local data
      await removeOfflineData(data.id);
    } catch (error) {
      console.error('Sync failed:', error);
    }
  }
}

PWA Manifest File

// public/manifest.json
{
  "name": "Astro PWA Application",
  "short_name": "AstroPWA",
  "description": "Progressive Web Application built with Astro",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#000000",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ],
  "categories": ["productivity", "utilities"],
  "screenshots": [
    {
      "src": "/screenshots/desktop.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide"
    },
    {
      "src": "/screenshots/mobile.png",
      "sizes": "360x640",
      "type": "image/png",
      "form_factor": "narrow"
    }
  ]
}

PWA Component Integration

---
// src/components/PWAInstallPrompt.astro
---

<div id="pwa-install-prompt" class="pwa-prompt hidden">
  <div class="pwa-prompt-content">
    <h3>Install App</h3>
    <p>Install this app on your device for a better experience!</p>
    <div class="pwa-prompt-actions">
      <button id="pwa-install-btn" class="btn-primary">Install</button>
      <button id="pwa-dismiss-btn" class="btn-secondary">Cancel</button>
    </div>
  </div>
</div>

<script>
class PWAInstaller {
  private deferredPrompt: any = null;
  private installPrompt: HTMLElement | null = null;

  constructor() {
    this.installPrompt = document.getElementById('pwa-install-prompt');
    this.init();
  }

  private init() {
    // Listen for beforeinstallprompt event
    window.addEventListener('beforeinstallprompt', (e) => {
      e.preventDefault();
      this.deferredPrompt = e;
      this.showInstallPrompt();
    });

    // Listen for app installation
    window.addEventListener('appinstalled', () => {
      console.log('PWA installed');
      this.hideInstallPrompt();
      this.deferredPrompt = null;
    });

    // Bind button events
    document.getElementById('pwa-install-btn')?.addEventListener('click', () => {
      this.installApp();
    });

    document.getElementById('pwa-dismiss-btn')?.addEventListener('click', () => {
      this.hideInstallPrompt();
    });

    // Register Service Worker
    this.registerServiceWorker();
  }

  private showInstallPrompt() {
    this.installPrompt?.classList.remove('hidden');
  }

  private hideInstallPrompt() {
    this.installPrompt?.classList.add('hidden');
  }

  private async installApp() {
    if (!this.deferredPrompt) return;

    this.deferredPrompt.prompt();
    const { outcome } = await this.deferredPrompt.userChoice;

    if (outcome === 'accepted') {
      console.log('User accepted the install');
    } else {
      console.log('User dismissed the install');
    }

    this.deferredPrompt = null;
    this.hideInstallPrompt();
  }

  private async registerServiceWorker() {
    if ('serviceWorker' in navigator) {
      try {
        const registration = await navigator.serviceWorker.register('/sw.js');
        console.log('Service Worker registered successfully:', registration);

        // Listen for updates
        registration.addEventListener('updatefound', () => {
          const newWorker = registration.installing;
          if (newWorker) {
            newWorker.addEventListener('statechange', () => {
              if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
                // New version available
                this.showUpdatePrompt();
              }
            });
          }
        });
      } catch (error) {
        console.error('Service Worker registration failed:', error);
      }
    }
  }

  private showUpdatePrompt() {
    if (confirm('A new version is available. Refresh the page?')) {
      window.location.reload();
    }
  }
}

// Initialize PWA installer
new PWAInstaller();
</script>

<style>
.pwa-prompt {
  @apply fixed bottom-4 left-4 right-4 bg-white shadow-lg rounded-lg p-4 z-50;
  @apply border border-gray-200;
}

.pwa-prompt.hidden {
  @apply hidden;
}

.pwa-prompt-content h3 {
  @apply text-lg font-semibold mb-2;
}

.pwa-prompt-content p {
  @apply text-gray-600 mb-4;
}

.pwa-prompt-actions {
  @apply flex space-x-2;
}

.btn-primary {
  @apply bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700;
}

.btn-secondary {
  @apply bg-gray-200 text-gray-800 px-4 py-2 rounded-md hover:bg-gray-300;
}

@media (min-width: 768px) {
  .pwa-prompt {
    @apply max-w-sm right-4 left-auto;
  }
}
</style>

Internationalization (i18n)

Multilingual Configuration

// src/i18n/config.ts

export const languages = {
  'zh-CN': 'Simplified Chinese',
  'en': 'English',
  'ja': 'Japanese',
  'ko': 'Korean',
};

export const defaultLang = 'zh-CN';

export interface Translation {
  [key: string]: string | Translation;
}

export const translations: Record<string, Translation> = {
  'zh-CN': {
    nav: {
      home: '首页',
      about: '关于',
      blog: '博客',
      contact: '联系',
    },
    common: {
      loading: '加载中...',
      error: '出错了',
      retry: '重试',
      cancel: '取消',
      confirm: '确认',
    },
    blog: {
      title: '博客',
      readMore: '阅读更多',
      publishedAt: '发布于',
      tags: '标签',
    },
  },
  'en': {
    nav: {
      home: 'Home',
      about: 'About',
      blog: 'Blog',
      contact: 'Contact',
    },
    common: {
      loading: 'Loading...',
      error: 'Something went wrong',
      retry: 'Retry',
      cancel: 'Cancel',
      confirm: 'Confirm',
    },
    blog: {
      title: 'Blog',
      readMore: 'Read More',
      publishedAt: 'Published at',
      tags: 'Tags',
    },
  },
};

export function getLangFromUrl(url: URL): string {
  const [, lang] = url.pathname.split('/');
  if (lang in languages) return lang;
  return defaultLang;
}

export function useTranslations(lang: string) {
  return function t(key: string): string {
    const keys = key.split('.');
    let value: any = translations[lang] || translations[defaultLang];

    for (const k of keys) {
      value = value?.[k];
    }

    return value || key;
  };
}

Multilingual Page Generation

---
// src/pages/[lang]/blog/[slug].astro

import { languages, getLangFromUrl, useTranslations } from '../../../i18n/config';
import { getCollection } from 'astro:content';

export async function getStaticPaths() {
  const posts = await getCollection('blog');

  return Object.keys(languages).flatMap(lang =>
    posts.map(post => ({
      params: {
        lang,
        slug: post.slug
      },
      props: {
        post,
        lang
      }
    }))
  );
}

const { post, lang } = Astro.props;
const t = useTranslations(lang);

// Get localized post content
const localizedPost = post.data[lang] || post.data[defaultLang];
---

<html lang={lang}>
<head>
  <title>{localizedPost.title}</title>
  <meta name="description" content={localizedPost.description} />

  <!-- Language alternate links -->
  {Object.keys(languages).map(altLang => (
    <link
      rel="alternate"
      hreflang={altLang}
      href={`/${altLang}/blog/${post.slug}`}
    />
  ))}

  <!-- Default language -->
  <link
    rel="alternate"
    hreflang="x-default"
    href={`/${defaultLang}/blog/${post.slug}`}
  />
</head>
<body>
  <nav>
    <LanguageSwitcher currentLang={lang} currentPath={Astro.url.pathname} />
  </nav>

  <main>
    <article class="blog-post">
      <header>
        <h1>{localizedPost.title}</h1>
        <p class="meta">
          {t('blog.publishedAt')} {localizedPost.publishDate.toLocaleDateString(lang)}
        </p>

        {localizedPost.tags && (
          <div class="tags">
            <span>{t('blog.tags')}:</span>
            {localizedPost.tags.map(tag => (
              <a href={`/${lang}/blog/tags/${tag}`} class="tag">#{tag}</a>
            ))}
          </div>
        )}
      </header>

      <div class="content" set:html={localizedPost.content} />
    </article>
  </main>
</body>
</html>

Language Switcher Component

<!-- src/components/LanguageSwitcher.vue -->
<template>
  <div class="language-switcher">
    <button
      @click="toggleDropdown"
      class="language-button"
      :aria-expanded="isOpen"
      aria-haspopup="true"
    >
      <GlobeIcon class="w-5 h-5" />
      <span>{{ languages[currentLang] }}</span>
      <ChevronDownIcon class="w-4 h-4" />
    </button>

    <div v-show="isOpen" class="language-dropdown">
      <a
        v-for="(name, code) in languages"
        :key="code"
        :href="getLocalizedPath(code)"
        :class="['language-option', { active: code === currentLang }]"
        @click="switchLanguage(code)"
      >
        <span class="language-name">{{ name }}</span>
        <span class="language-code">{{ code }}</span>
      </a>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { languages } from '../i18n/config';
import { GlobeIcon, ChevronDownIcon } from '@heroicons/vue/24/outline';

interface Props {
  currentLang: string;
  currentPath: string;
}

const props = defineProps<Props>();
const isOpen = ref(false);

function toggleDropdown() {
  isOpen.value = !isOpen.value;
}

function getLocalizedPath(langCode: string): string {
  // Remove current language prefix, add new language prefix
  const pathWithoutLang = props.currentPath.replace(`/${props.currentLang}`, '');
  return `/${langCode}${pathWithoutLang}`;
}

function switchLanguage(langCode: string) {
  isOpen.value = false;
  // Save user language preference
  localStorage.setItem('preferred-language', langCode);
}

function handleClickOutside(event: MouseEvent) {
  const target = event.target as Element;
  if (!target.closest('.language-switcher')) {
    isOpen.value = false;
  }
}

onMounted(() => {
  document.addEventListener('click', handleClickOutside);
});

onUnmounted(() => {
  document.removeEventListener('click', handleClickOutside);
});
</script>

<style scoped>
.language-switcher {
  @apply relative;
}

.language-button {
  @apply flex items-center space-x-2 px-3 py-2 rounded-md;
  @apply bg-gray-100 hover:bg-gray-200 transition-colors;
}

.language-dropdown {
  @apply absolute top-full left-0 mt-1 bg-white shadow-lg rounded-md py-1 z-50;
  @apply border border-gray-200 min-w-40;
}

.language-option {
  @apply flex items-center justify-between px-4 py-2 text-sm;
  @apply hover:bg-gray-100 transition-colors;
}

.language-option.active {
  @apply bg-blue-50 text-blue-600;
}

.language-name {
  @apply font-medium;
}

.language-code {
  @apply text-xs text-gray-500 uppercase;
}
</style>
Key Takeaways
  1. Hybrid Rendering: Properly use static generation and server rendering
  2. Middleware System: Implement authentication, security, rate limiting, and other functions
  3. PWA Features: Provide native app-like user experience
  4. Internationalization Support: Complete multilingual solution
  5. API Integration: Best practices for RESTful and GraphQL APIs
Important Notes
  • SSR mode increases server load and should be used judiciously
  • PWA features require HTTPS environment to work properly
  • Internationalization must consider SEO and search engine indexing
  • Middleware execution order is crucial and needs careful design