Menu

基于 Nexty 服务端的浏览器插件开发指南

概述

在开发浏览器插件的时候,我们通常需要一个服务端,而 Nexty 具备了 Supabase 认证、Stripe 支付等等 SaaS 产品必备的服务端能力,你完全可以基于 Nexty 开发你的服务端功能,让浏览器插件开发也变得快起来。

本文将详细介绍如何基于 Nexty 模板的服务端能力,实现浏览器插件登录和业务数据同步。

以下内容主要解决3个需求:

  • 浏览器插件完成公开数据请求
  • 浏览器插件完成登录
  • 浏览器插件完成需要鉴权的非公开数据请求

测试方式

本文主要讲解思路,代码不是完整的,你可以 clone 以下完整源码,在本地进行测试:

git clone https://github.com/WeNextDev/nexty.dev.git
cd nexty.dev
git checkout extension-request-demo
 
pnpm install
pnpm dev
git clone https://github.com/WeNextDev/nexty-extension-request-demo.git
cd nexty-extension-request-demo
 
pnpm install
pnpm dev

需求1:公开数据请求

假设我们的服务端有一个接口,返回的是公开数据,请求路径是这样的:

Loading diagram...

我们先在 Nexty 代码里编写一个公开的、不需要鉴权的接口:

app/api/extension-demo/public-data/route.ts
import { apiResponse } from "@/lib/api-response";
 
export async function GET() {
  const publicData = {
    latestAnnouncement:
      "🎉 Welcome to Nexty.dev! This is a public endpoint.",
  };
 
  try {
    const response = apiResponse.success(publicData);
    response.headers.set(
      "Cache-Control",
      "public, s-maxage=86400, stale-while-revalidate=43200", // 添加缓存头,避免频繁请求,减轻服务端压力
    );
  } catch (error) {
    console.error("Error fetching public data:", error);
    return apiResponse.serverError();
  }
} 

在浏览器插件前端发起请求:

popup.tsx
useEffect(() => {
  chrome.runtime.sendMessage({ type: "GET_PUBLIC_DATA" }, (response) => {
    if (response.success) {
      setPublicDataState({ data: response.data, error: null, isLoading: false });
    } else {
      setPublicDataState({ data: null, error: response.error, isLoading: false });
    }
  });
}, []);

在浏览器插件 background 转发请求:

background/index.ts
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  if (request.type === "GET_PUBLIC_DATA") {
    apiFetch("/api/extension-demo/public-data", false, "public_data_cache").then(sendResponse);
    return true;
  }
 
  return false;
});

现在浏览器插件前端就能拿到公开接口返回的数据了。

需求2:插件登录认证与登录态同步

认证流程图

先看一下认证流程图

Loading diagram...

看起来有点复杂,核心原则就是 3 点:

  1. Nexty 网站是唯一的认证中心:浏览器插件不处理登录凭据,只“借用”网站的登录态
  2. 服务端验证:所有权限检查都在 Nexty 后端进行,确保数据安全
  3. 实时同步:浏览器插件每次打开都会验证最新的用户状态,保证信息准确性

实现步骤

在 Nexty 项目中创建获取用户登录态的 API app/api/user-status/route.ts,并且这里有一步很重要的是,要把 Cookie 完整地返回,确保插件端的登录态能够持续:

app/api/user-status/route.ts
// ... other code ...
 
    // 未登录的返回信息:
    if (!user) {
      return apiResponse.success({ isLoggedIn: false })
    }
 
    // 已登录的返回信息
    const responseData = {
      isLoggedIn: true, // 已登录的标识
      id: user.id,
      email: user.email,
      plan: profile?.role || 'user',
    }
 
    const finalResponse = apiResponse.success(responseData)
    // 重要!返回 Cookie
    response.cookies.getAll().forEach((cookie) => {
      finalResponse.cookies.set(cookie)
    })
    return finalResponse
 
// ... other code ...

在浏览器插件前端发起请求:

popup.tsx
useEffect(() => {
  chrome.runtime.sendMessage({ type: "GET_USER_STATUS" }, (response) => {
    if (response.success) {
      setUserStatusState({ data: response.data, error: null, isLoading: false });
    } else {
      if (!cachedStatus) setUserStatusState({ data: null, error: response.error, isLoading: false });
    }
  });
}, []);

在浏览器插件 background 转发请求:

background/index.ts
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  if (request.type === "GET_USER_STATUS") {
    apiFetch("/api/extension-demo/user-status", true, "user_status_cache").then(sendResponse);
    return true;
  }
 
  return false;
});

如果用户已登录,则展示用户信息;如果用户未登录,则展示登录按钮,按钮点击会调用 chrome.tabs.create 方法,在新标签页打开登录页面:

    const handleLoginClick = () => {
      chrome.tabs.create({ url: process.env.PLASMO_PUBLIC_SITE_LOGIN_URL });
    };
 
    return (
    <div>
      <h2>User Info</h2>
      {userStatusState.data && (
        userStatusState.data.isLoggedIn ? (
          <div>
            <p>Welcome, <strong>{userStatusState.data.email}</strong>!</p>
            <p>Your user role: <strong>{userStatusState.data.plan}</strong></p>
          </div>
        ) : (
          <div>
            <h4>Please login to use</h4>
            <button onClick={handleLoginClick}>Go to login</button>
          </div>
        )
      )}
    </div>
  );

在本文档提供的浏览器插件端代码里,还应用了乐观更新的理念,即先展示旧的缓存数据,请求完成后使用新数据覆盖掉旧数据:

popup.tsx
useEffect(() => {
  // 先尝试读取缓存
  const cachedStatus = await storage.get<UserStatus>("user_status_cache");
  if (cachedStatus) {
    setUserStatusState({ data: cachedStatus, error: null, isLoading: false });
  }
 
  chrome.runtime.sendMessage({ type: "GET_USER_STATUS" }, (response) => {
    // ... other code ...
  });
}, []);

除此之外,为了让用户刚打开浏览器插件就能看到用户状态,我们还可以在插件安装和浏览器启动的时候就发起一次请求:

background/index.ts
// When the extension installed or the browser starts
// get the status once as the initial cache
chrome.runtime.onInstalled.addListener(() => {
  apiFetch("/api/extension-demo/user-status", true, "user_status_cache");
});
 
chrome.runtime.onStartup.addListener(() => {
  apiFetch("/api/extension-demo/user-status", true, "user_status_cache");
});

需求3:非公开数据请求

实际产品开发过程中,肯定有一部分数据需要登录用户才能访问,例如用户的订阅状态。

我们创建一个获取用户订阅状态的接口作为示例:

app/api/extension-demo/user-benefits/route.ts
export async function GET(request: NextRequest) {
 
  try {
    // 1. 判断登录状态
    const {
      data: { user },
    } = await supabase.auth.getUser();
    if (!user) {
      return apiResponse.unauthorized();
    }
 
    // 2. 调用 Server Actions,不需要单独写处理逻辑
    const benefits = await getUserBenefits(user.id);
 
    // 3. 返回
    const finalResponse = apiResponse.success(benefits);
    response.cookies.getAll().forEach((cookie) => {
      finalResponse.cookies.set(cookie);
    });
    return finalResponse;
  } catch (error) {
    console.error("Error fetching user benefits:", error);
    return apiResponse.serverError();
  }
} 

浏览器前端请求前需要先判断登录状态,仅已登录用户才进行请求:

popup.tsx
if (!userStatusState.isLoading && userStatusState.data?.isLoggedIn) {
 
  const cachedBenefits = await storage.get<UserBenefits>("user_benefits_cache");
  if (cachedBenefits) {
    setUserBenefitsState({ data: cachedBenefits, error: null, isLoading: false });
  }
 
  chrome.runtime.sendMessage({ type: "GET_USER_BENEFITS" }, (response) => {
    if (response.success) {
      setUserBenefitsState({ data: response.data, error: null, isLoading: false });
    } else {
      if (!cachedBenefits) setUserBenefitsState({ data: null, error: response.error, isLoading: false });
    }
  });
}

浏览器插件 background 仍然进行请求转发:

background/index.ts
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  if (request.type === "GET_USER_BENEFITS") {
    apiFetch("/api/extension-demo/user-benefits", true, "user_benefits_cache").then(sendResponse);
    return true;
  }
 
  return false;
});

其他最佳实践建议

  • 使用独立 API 前缀:为浏览器插件提供的 API 使用独立前缀,方便处理公共逻辑

  • 利用缓存减轻服务端压力:对于实时性要求不高的数据,要利用 Next.js 的缓存能力或者 http 请求返回添加缓存头的方式,减轻服务器的压力

    提示

    参考 Nexty 源码 extenison 分支的 app/api/extension-demo/public-data/route.ts

  • 加密请求:浏览器插件请求应该添加加密签名头,这样即使是公开的接口也能多一层防护

    提示

    Demo 源码位置:

    • Nexty 源码 extension 分支的 middleware.tslib/crypto-utils.ts
    • 浏览器插件源码的 background/index.tsbackground/crypto-utils.ts

    crypto-utils.ts 的加密方法仅作为示例,如果需要更复杂的加密方法,可以让 AI 为你编写,它们很擅长加密算法。