tRPC v11 với Next.js 15: An toàn kiểu dữ liệu từ đầu đến cuối, Server Actions & TanStack Query v5

Mục lục bài viết(14 mục)
tRPC v11, kết hợp với App Router và Server Actions của Next.js 15, mang đến một framework mạnh mẽ để xây dựng các ứng dụng TypeScript full-stack. Hướng dẫn này trình bày chi tiết một phương pháp kiến trúc tận dụng tRPC cho các tương tác API an toàn kiểu, tích hợp Server Actions cho các mẫu mutation cụ thể và điều phối việc tìm nạp dữ liệu với TanStack Query v5. Mục tiêu là đạt được tính an toàn kiểu end-to-end tại thời điểm biên dịch mà không cần đến các công cụ tạo schema như Protobuf hay GraphQL.
Tổng quan kiến trúc
Kiến trúc được đề xuất kết hợp API kiểu RPC của tRPC với Next.js Server Actions. tRPC xử lý việc tìm nạp dữ liệu phức tạp, cập nhật thời gian thực (thông qua subscriptions, mặc dù không được đề cập rộng rãi ở đây) và các mutation đa năng. Server Actions được sử dụng cho các cập nhật UI lạc quan trên các mutation đơn giản, gửi biểu mẫu và các trường hợp mà việc thao tác dữ liệu trực tiếp phía máy chủ mà không cần một vòng lặp API đầy đủ là có lợi. TanStack Query v5 quản lý bộ nhớ đệm phía client, xác thực lại và đồng bộ hóa.
Các thành phần cốt lõi
- tRPC Server: Được định nghĩa trong các tuyến API của Next.js (ví dụ:
app/api/trpc/[trpc]/route.ts), hiển thị các thủ tục an toàn kiểu. - tRPC Client: Được cấu hình cho các thành phần React, tích hợp với TanStack Query.
- Next.js Server Actions: Các hàm được đánh dấu
use serverđể thực thi trực tiếp phía máy chủ từ các thành phần client. - TanStack Query v5: Lớp tìm nạp và lưu trữ dữ liệu phía client.
- Zod: Xác thực schema cho đầu vào tRPC và payload của Server Action.
So sánh kiến trúc: tRPC vs. Server Actions
| Tính năng | Thủ tục tRPC | Next.js Server Actions |
|---|---|---|
| Gọi thực thi | HTTP (POST/GET), kiểu RPC | Gọi hàm trực tiếp (kiểu RPC) |
| An toàn kiểu | End-to-end, tại thời điểm biên dịch | End-to-end, tại thời điểm biên dịch |
| Gộp nhóm | Tự động (HTTP) | Không có gộp nhóm tự nhiên |
| Bộ nhớ đệm | TanStack Query | React Cache, revalidatePath, revalidateTag |
| Xử lý lỗi | Lỗi HTTP tiêu chuẩn, tRPC transformers | try/catch trên client, useFormStatus |
| Middleware | Tích hợp sẵn (auth, rate-limit) | Custom wrappers, next/cache |
| Trường hợp sử dụng | Các truy vấn phức tạp, mutation, subscriptions, API tổng quát | Gửi biểu mẫu, cập nhật lạc quan, mutation đơn giản |
| Mạng | Yêu cầu/Phản hồi HTTP | Gọi hàm được tuần tự hóa qua HTTP |
Thiết lập tRPC Server
Máy chủ tRPC được định nghĩa trong app/api/trpc/[trpc]/route.ts. Tệp này đóng vai trò là điểm vào cho tất cả các yêu cầu tRPC.
// src/server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { ZodError } from 'zod';
import superjson from 'superjson';
import { type NextRequest } from 'next/server';
interface CreateContextOptions {
headers: Headers;
// Add any other context properties you need, e.g., session
userId?: string;
}
/**
* This is the actual context you'll use in your router. It will be used to process every request
* that goes through your tRPC router.
*/
export const createTRPCContext = async (opts: CreateContextOptions) => {
// Simulate authentication or session retrieval
const userId = opts.headers.get('x-user-id') || undefined; // Example: get from header
return {
headers: opts.headers,
userId,
};
};
/**
* Initializer for tRPC.
* @see https://trpc.io/docs/v11/server/initialization
*/
const t = initTRPC.context<typeof createTRPCContext>().transformer(superjson).create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.code === 'BAD_REQUEST' && error.cause instanceof ZodError
? error.cause.flatten()
: null,
},
};
},
});
/**
* Reusable middleware that enforces users are logged in.
*/
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
// Infers a new type for the context with `userId` now being non-nullable
userId: ctx.userId,
},
});
});
/**
* Export reusable router and procedure helpers
*/
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
// src/server/routers/_app.ts
import { z } from 'zod';
import { createTRPCRouter, publicProcedure, protectedProcedure } from '../trpc';
// Example: Simulate a database
const posts: { id: string; title: string; content: string; authorId: string }[] = [];
export const appRouter = createTRPCRouter({
post: createTRPCRouter({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const post = posts.find(p => p.id === input.id);
if (!post) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Post not found' });
}
return post;
}),
create: protectedProcedure
.input(z.object({ title: z.string().min(1), content: z.string().min(1) }))
.mutation(({ input, ctx }) => {
const newPost = {
id: Math.random().toString(36).substring(2, 9),
title: input.title,
content: input.content,
authorId: ctx.userId, // userId is guaranteed to be present by protectedProcedure
};
posts.push(newPost);
return newPost;
}),
getAll: publicProcedure
.query(() => {
return posts;
}),
}),
user: createTRPCRouter({
getProfile: protectedProcedure
.query(({ ctx }) => {
// In a real app, fetch user profile from DB using ctx.userId
return { id: ctx.userId, name: `User ${ctx.userId}` };
}),
}),
});
// Export type definition of API
export type AppRouter = typeof appRouter;
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app';
import { createTRPCContext } from '@/server/trpc';
import { type NextRequest } from 'next/server';
/**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
* handling a HTTP request (e.g., when you make requests from the client).
*/
const createContext = async (req: NextRequest) => {
return createTRPCContext({
headers: req.headers,
});
};
const handler = (req: NextRequest) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => createContext(req),
onError({ error, path }) {
console.error(`❌ tRPC failed on ${path ?? '<no-path>'}: ${error.message}`);
},
});
export { handler as GET, handler as POST };
Thiết lập tRPC Client với TanStack Query v5
Thiết lập client bao gồm việc tạo một thể hiện client tRPC và tích hợp nó với QueryClientProvider của TanStack Query. Hydration rất quan trọng đối với các trang được render phía máy chủ (SSR) hoặc được tạo tĩnh (SSG).
// src/trpc/react.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink, loggerLink } from '@trpc/client';
import { createTRPCReact } from '@trpc/react-query';
import { useState } from 'react';
import superjson from 'superjson';
import { type AppRouter } from '@/server/routers/_app';
const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching on first mount.
staleTime: 60 * 1000,
},
},
});
let clientQueryClient: QueryClient | undefined = undefined;
const getQueryClient = () => {
if (typeof window === 'undefined') {
// Server: always make a new query client
return createQueryClient();
} else {
// Browser: make a new query client if we don't already have one
// This is to make sure we use the same query client across the entire browser session
return (clientQueryClient ??= createQueryClient());
}
};
export const api = createTRPCReact<AppRouter>();
export function TRPCReactProvider(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();
const [trpcClient] = useState(() =>
api.createClient({
links: [
loggerLink({
enabled: (op) =>
process.env.NODE_ENV === 'development' ||
(op.direction === 'down' && op.result instanceof Error),
}),
httpBatchLink({
url: '/api/trpc', // Relative path for Next.js API routes
transformer: superjson,
}),
],
}),
);
return (
<QueryClientProvider client={queryClient}>
<api.Provider client={trpcClient} queryClient={queryClient}>
{props.children}
</api.Provider>
</QueryClientProvider>
);
}
// src/app/layout.tsx
import { TRPCReactProvider } from '@/trpc/react';
import './globals.css';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<TRPCReactProvider>{children}</TRPCReactProvider>
</body>
</html>
);
}
Tích hợp Server Actions
Server Actions có thể được sử dụng cho các mutation có lợi từ tương tác trực tiếp với máy chủ, đặc biệt đối với các biểu mẫu hoặc cập nhật lạc quan.
// src/app/actions.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
// Simulate a database for Server Actions
const serverActionPosts: { id: string; title: string; content: string }[] = [];
const createPostSchema = z.object({
title: z.string().min(1, 'Title is required'),
content: z.string().min(1, 'Content is required'),
});
export async function createPostAction(prevState: any, formData: FormData) {
const rawFormData = {
title: formData.get('title'),
content: formData.get('content'),
};
const validatedFields = createPostSchema.safeParse(rawFormData);
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Failed to create post.',
};
}
const { title, content } = validatedFields.data;
// Simulate DB operation
const newPost = {
id: Math.random().toString(36).substring(2, 9),
title,
content,
};
serverActionPosts.push(newPost);
// Revalidate paths to show new data
revalidatePath('/server-actions-posts');
redirect('/server-actions-posts'); // Redirect after successful creation
return { message: 'Post created successfully.' };
}
export async function getPostsAction() {
// Simulate fetching posts
return serverActionPosts;
}
Tìm nạp & Thay đổi dữ liệu kết hợp
Kết hợp tRPC và Server Actions.
// src/app/page.tsx
import { api } from '@/trpc/react';
import { createPostAction, getPostsAction } from './actions';
import { PostForm } from './post-form'; // A client component for the form
export default async function HomePage() {
// Fetch posts using tRPC on the server (SSR)
const trpcPosts = await api.ssr.post.getAll.fetch();
// Fetch posts using Server Action on the server (SSR)
const serverActionPosts = await getPostsAction();
return (
<main className="p-4">
<h1 className="text-2xl font-bold mb-4">Full-Stack Type Safety with tRPC & Server Actions</h1>
<section className="mb-8">
<h2 className="text-xl font-semibold mb-2">tRPC Posts (SSR)</h2>
<ul className="list-disc pl-5">
{trpcPosts.map((post) => (
<li key={post.id}>
<strong>{post.title}</strong> by {post.authorId}
<p className="text-sm text-gray-600">{post.content}</p>
</li>
))}
</ul>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold mb-2">Server Action Posts (SSR)</h2>
<ul className="list-disc pl-5">
{serverActionPosts.map((post) => (
<li key={post.id}>
<strong>{post.title}</strong>
<p className="text-sm text-gray-600">{post.content}</p>
</li>
))}
</ul>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold mb-2">Create Post (tRPC Mutation - Client)</h2>
<CreateTRPCPostForm />
</section>
<section>
<h2 className="text-xl font-semibold mb-2">Create Post (Server Action - Client)</h2>
<PostForm /> {/* This component uses `useFormState` and `createPostAction` */}
</section>
</main>
);
}
// Client component for tRPC mutation
function CreateTRPCPostForm() {
const createPost = api.post.create.useMutation({
onSuccess: () => {
// Invalidate cache to refetch all posts after successful creation
api.post.getAll.invalidate();
alert('tRPC Post created!');
},
onError: (error) => {
alert(`Error creating tRPC post: ${error.message}`);
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const title = formData.get('title') as string;
const content = formData.get('content') as string;
createPost.mutate({ title, content });
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="trpc-title" className="block text-sm font-medium text-gray-700">Title</label>
<input type="text" id="trpc-title" name="title" required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
</div>
<div>
<label htmlFor="trpc-content" className="block text-sm font-medium text-gray-700">Content</label>
<textarea id="trpc-content" name="content" required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"></textarea>
</div>
<button type="submit" disabled={createPost.isLoading} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
{createPost.isLoading ? 'Creating...' : 'Create tRPC Post'}
</button>
</form>
);
}
// src/app/post-form.tsx
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { createPostAction } from './actions';
const initialState = {
message: '',
errors: undefined,
};
export function PostForm() {
const [state, formAction] = useFormState(createPostAction, initialState);
const { pending } = useFormStatus();
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="sa-title" className="block text-sm font-medium text-gray-700">Title</label>
<input type="text" id="sa-title" name="title" required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
{state.errors?.title && (
<p className="text-red-500 text-xs mt-1">{state.errors.title.join(', ')}</p>
)}
</div>
<div>
<label htmlFor="sa-content" className="block text-sm font-medium text-gray-700">Content</label>
<textarea id="sa-content" name="content" required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"></textarea>
{state.errors?.content && (
<p className="text-red-500 text-xs mt-1">{state.errors.content.join(', ')}</p>
)}
</div>
<button type="submit" disabled={pending} className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50">
{pending ? 'Creating...' : 'Create Server Action Post'}
</button>
{state.message && <p className="mt-2 text-sm text-green-600">{state.message}</p>}
</form>
);
}
Những vấn đề cần lưu ý khi triển khai & Khắc phục sự cố
-
Lỗi tuần tự hóa với
superjson:- Vấn đề: Bạn có thể gặp lỗi như "Cannot stringify arbitrary non-POJOs" hoặc "Cannot deserialize value" khi truyền các đối tượng phức tạp (ví dụ:
Date,Map,Set) giữa client và server. - Nguyên nhân: tRPC sử dụng
superjsontheo mặc định, nhưng nếu bạn quên cấu hình nó trên cả client và server, hoặc nếu một Server Action cố gắng truyền trực tiếp một đối tượng không thể tuần tự hóa, các vấn đề sẽ phát sinh. - Cách khắc phục: Đảm bảo
superjsonđược cấu hình tronginitTRPCtrên server vàhttpBatchLinktrên client. Đối với Server Actions, hãy tuần tự hóa/giải tuần tự hóa rõ ràng các kiểu phức tạp hoặc đảm bảo chúng là các đối tượng JavaScript thuần túy.
- Vấn đề: Bạn có thể gặp lỗi như "Cannot stringify arbitrary non-POJOs" hoặc "Cannot deserialize value" khi truyền các đối tượng phức tạp (ví dụ:
-
TRPCClientErrortrên phía máy chủ (SSR/RSC):- Vấn đề: Khi tìm nạp dữ liệu tRPC trong một Server Component (ví dụ:
await api.ssr.post.getAll.fetch()), bạn có thể thấyTRPCClientErrorhoặc các lỗi liên quan đến mạng. - Nguyên nhân: Client tRPC được cấu hình cho SSR cần biết URL đầy đủ nếu nó đang thực hiện một yêu cầu bên ngoài, hoặc nó cần được cấu hình để sử dụng đường dẫn tương đối nếu đó là một tuyến API nội bộ.
- Cách khắc phục: Đối với các tuyến API nội bộ, đảm bảo URL
httpBatchLinklà/api/trpc. Nếu triển khai lên môi trường giống Vercel, biến môi trườngVERCEL_URLcó thể được sử dụng để xây dựng một URL đầy đủ cho các cuộc gọi bên ngoài, nhưng đối với các cuộc gọi nội bộ trong Next.js, đường dẫn tương đối được ưu tiên. Thiết lậpcreateTRPCReactxử lý điều này bằng cách cung cấp mộtgetQueryClientriêng biệt cho server và client.
- Vấn đề: Khi tìm nạp dữ liệu tRPC trong một Server Component (ví dụ:
-
Hủy bỏ bộ nhớ đệm Next.js với Server Actions:
- Vấn đề: Sau khi một Server Action thay đổi dữ liệu, các thành phần phía client có thể không phản ánh ngay lập tức các thay đổi, hoặc
revalidatePath/revalidateTagdường như không hoạt động. - Nguyên nhân:
revalidatePathvàrevalidateTagchỉ làm mất hiệu lực Next.js Data Cache cho Server Components. Chúng không tự động làm mất hiệu lực bộ nhớ đệm phía client của TanStack Query. - Cách khắc phục: Nếu một Server Action sửa đổi dữ liệu cũng được tìm nạp bởi tRPC/TanStack Query, bạn phải tự động làm mất hiệu lực các khóa TanStack Query có liên quan trên client sau khi Server Action hoàn thành. Điều này có thể được thực hiện bằng cách gọi
queryClient.invalidateQueries()trong mộtuseEffectphía client theo dõi trạng thái của Server Action, hoặc bằng cách thực hiện một tRPC mutation sau đó làm mất hiệu lực.
- Vấn đề: Sau khi một Server Action thay đổi dữ liệu, các thành phần phía client có thể không phản ánh ngay lập tức các thay đổi, hoặc
-
Ngữ cảnh xác thực trong tRPC Middleware:
- Vấn đề:
ctx.userIdlàundefinedtrongprotectedProceduremặc dù người dùng đã đăng nhập. - Nguyên nhân: Hàm
createTRPCContextkhông trích xuất thông tin xác thực từ yêu cầu đến một cách chính xác. - Cách khắc phục: Đảm bảo
createTRPCContextđọc đúng các mã thông báo xác thực (ví dụ: từ tiêu đềAuthorization, cookie) và điền vào ngữ cảnh. Đối với Next.js App Router, cookie có thể được truy cập thông quacookies().get('token')từnext/headerstrong ngữ cảnh máy chủ.
- Vấn đề:
-
Lỗi xác thực Zod không được truyền đi:
- Vấn đề: Các biểu mẫu phía client sử dụng Server Actions hoặc tRPC không hiển thị chi tiết lỗi xác thực.
- Nguyên nhân: Trình định dạng lỗi trong tRPC hoặc việc xử lý lỗi trong Server Actions không được cấu trúc để trả về các lỗi Zod chi tiết.
- Cách khắc phục: Đối với tRPC, đảm bảo
errorFormattertronginitTRPCtrích xuất chi tiếtZodError. Đối với Server Actions,safeParsevà trả vềvalidatedFields.error.flatten().fieldErrorslà cách tiếp cận đúng, như đã trình bày. Thành phần client sau đó cần hiển thị các lỗi này.
Các câu hỏi thường gặp
1. Tại sao phải sử dụng tRPC khi Next.js Server Actions cung cấp tính an toàn kiểu?
tRPC và Server Actions giải quyết các nhu cầu khác nhau, mặc dù có một số điểm trùng lặp. tRPC cung cấp một lớp RPC đầy đủ tính năng với các tính năng như gộp nhóm tự động, subscriptions và một hệ thống middleware mạnh mẽ để xác thực, ghi nhật ký và giới hạn tốc độ. Nó lý tưởng cho việc tìm nạp dữ liệu phức tạp, cập nhật thời gian thực và các tương tác API tổng quát. Server Actions rất xuất sắc cho việc gửi biểu mẫu, cập nhật UI lạc quan và các mutation trực tiếp phía máy chủ mà một lớp API đầy đủ có thể là quá mức cần thiết. Tính an toàn kiểu là một lợi ích chung, nhưng hệ sinh thái của tRPC (tích hợp TanStack Query, transformers) trưởng thành hơn cho việc quản lý dữ liệu phức tạp.
2. Làm thế nào để xử lý xác thực và ủy quyền với thiết lập này?
Đối với tRPC, hãy triển khai xác thực trong createTRPCContext bằng cách phân tích cú pháp tiêu đề hoặc cookie để xác định người dùng. Sau đó, sử dụng tRPC middleware (ví dụ: enforceUserIsAuthed) để bảo vệ các thủ tục. Đối với Server Actions, xác thực phải được xử lý trong chính action đó, thường là bằng cách đọc cookie hoặc dữ liệu phiên bằng cách sử dụng các tiện ích next/headers. Ủy quyền (kiểm tra xem người dùng có quyền thực hiện một hành động hay không) nên được thực hiện trong cả thủ tục tRPC và Server Actions, sau khi xác thực.
3. Tôi có thể sử dụng các truy vấn tRPC trong Server Components không?
Có, như đã trình bày trong app/page.tsx. Bạn có thể sử dụng await api.ssr.yourProcedure.fetch() trực tiếp trong một Server Component. Điều này thực hiện một cuộc gọi hàm trực tiếp trên máy chủ, bỏ qua HTTP cho các cuộc gọi nội bộ và cung cấp tính an toàn kiểu đầy đủ. Đây là một tính năng mạnh mẽ cho SSR/SSG.
4. Cách tốt nhất để quản lý các kiểu dùng chung giữa tRPC và Server Actions là gì?
Định nghĩa các schema Zod và các kiểu TypeScript của bạn trong một thư mục dùng chung (ví dụ: src/schemas hoặc src/types). Cả trình xác thực đầu vào tRPC và trình xác thực payload của Server Action sau đó có thể nhập và sử dụng các schema này, đảm bảo tính nhất quán và an toàn kiểu trên toàn ứng dụng của bạn.
5. Làm thế nào để triển khai cập nhật lạc quan với Server Actions và TanStack Query?
Mặc dù Server Actions không tích hợp trực tiếp với các cập nhật lạc quan của TanStack Query, bạn có thể kết hợp chúng. Khi một Server Action được kích hoạt, bạn có thể cập nhật thủ công bộ nhớ đệm TanStack Query bằng cách sử dụng queryClient.setQueryData trước khi Server Action hoàn thành. Nếu Server Action thất bại, bạn có thể hoàn nguyên bộ nhớ đệm. Sau một Server Action thành công, bạn thường gọi queryClient.invalidateQueries() để tìm nạp lại dữ liệu và đảm bảo tính nhất quán. Điều này đòi hỏi sự điều phối cẩn thận ở phía client.
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

TanStack Query v5 với Next.js 15: Cập nhật lạc quan, đồng bộ hóa bộ nhớ đệm & Server Actions
Hướng dẫn toàn diện về TanStack Query v5 với Next.js 15: cập nhật lạc quan, đồng bộ hóa bộ nhớ đệm và Server Actions với kiến trúc cấp độ sản xuất và ví dụ mã.
Read more
React 19 Actions trong thực tế: useActionState, useOptimistic & khả năng phục hồi của Server Action
Hướng dẫn toàn diện về React 19 Actions trong thực tế: useActionState, useOptimistic và khả năng phục hồi của Server Action với kiến trúc cấp độ sản phẩm và ví dụ mã.
Read more
Next.js 15 Server Actions vs Route Handlers: So sánh kiến trúc chuyên sâu
Nắm vững khi nào nên chọn Server Actions so với Route Handlers trong Next.js 15, đi sâu vào progressive enhancement, hành vi caching, giao thức RPC và các ranh giới bảo mật.
Read more