/** * Apollo Client(v3.0 P0-3,ARCHITECTURE.md §3.4 V3-A2 / §5.1 / §5.2) * * 双端使用: * - 客户端:HttpLink 指向同域 `/api/graphql`,JWT 由 httpOnly cookie 经代理注入 * - 服务端(RSC):直连 `APOLLO_ROUTER_URL`(内网,由 middleware 注入身份头) * * APQ(Automatic Persisted Queries,v2.1 M3 安全加固): * - 生产环境前端只发 query hash(sha256),不发明文 query * - apollo-router 通过 hash 查找 pq-manifest.json 中的 query 文本 * - 防止攻击者通过 DevTools 构造任意查询探测 schema * - 开发模式可设 NEXT_PUBLIC_APOLLO_APQ=false 关闭 APQ 便于调试 * * 安全(V3-A2): * - 客户端不再注入 Authorization 头(token 全程不出 httpOnly cookie) * - 客户端不再读 localStorage.edu_token(方案已废止) * - 客户端 credentials:"include" 让 cookie 流向同域 /api/graphql * * 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§5.1、§5.2 */ import { ApolloClient, InMemoryCache, HttpLink, from } from "@apollo/client"; import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries"; import { sha256 } from "crypto-hash"; /** * 服务端 RSC 直连 apollo-router URL(仅服务端可用,浏览器走同域代理)。 * 不挂 NEXT_PUBLIC_ 前缀 → 不打包进客户端 bundle。 */ const SERVER_APOLLO_ROUTER_URL = process.env.APOLLO_ROUTER_URL || "http://localhost:3000/graphql"; /** * 客户端同域代理路径(V3-A2):浏览器只发同域请求, * 由 /api/graphql Route Handler 取 httpOnly cookie 中的 JWT 并转发。 */ const CLIENT_PROXY_URL = "/api/graphql"; // 开发模式可关闭 APQ 便于调试(NEXT_PUBLIC_APOLLO_APQ=false) // 生产环境默认启用(未设置或设置为 true 均启用) const APQ_ENABLED = process.env.NEXT_PUBLIC_APOLLO_APQ !== "false"; // MSW 启用时跳过 SSR 查询(P1-5):浏览器端由 MSW SW 拦截 /api/graphql const MSW_ENABLED = process.env.NEXT_PUBLIC_MSW === "1"; /** * 创建 Apollo Client 实例。 * * @param options 可选: * - serverSide: true 表示服务端 RSC 模式(直连 router),false/省略表示客户端(走 /api/graphql) * * 客户端不再接受 getAuthToken 参数:JWT 已迁至 httpOnly cookie, * JS 永远拿不到 token(ARCHITECTURE.md §3.4 V3-A2 / §11.7 红线 #2)。 */ export function createApolloClient( options: { serverSide?: boolean } = {}, ): ApolloClient { const isServer = options.serverSide ?? typeof window === "undefined"; const httpLink = new HttpLink({ // MSW 启用时(P1-5):SSR 端也走同域 /api/graphql,由 Route Handler 返回 mock 数据 uri: isServer && !MSW_ENABLED ? SERVER_APOLLO_ROUTER_URL : CLIENT_PROXY_URL, // 客户端:同域请求,cookie 自动随行;服务端:直连 router 不需要 cookie credentials: isServer ? "omit" : "include", }); // Link 链顺序:pqLink → httpLink // - pqLink 将 query 替换为 hash(启用时) // - httpLink 发送请求(含 cookie) // 客户端不再需要 authLink(token 注入由 /api/graphql 代理负责) const link = APQ_ENABLED ? from([createPersistedQueryLink({ sha256 }), httpLink]) : from([httpLink]); return new ApolloClient({ link, cache: new InMemoryCache(), ssrMode: isServer, defaultOptions: { query: { errorPolicy: "all", fetchPolicy: isServer ? "no-cache" : "cache-first", }, watchQuery: { errorPolicy: "all", }, }, }); } let clientSingleton: ApolloClient | null = null; /** * 获取客户端 Apollo Client 单例(浏览器侧复用缓存)。 * * 注意:不再接受 getAuthToken 参数(V3-A2 移除 localStorage 方案)。 */ export function getApolloClient(): ApolloClient { if (typeof window === "undefined") { return createApolloClient({ serverSide: true }); } if (!clientSingleton) { clientSingleton = createApolloClient({ serverSide: false }); } return clientSingleton; }