导入保护(Import Protection)
实验性
导入保护是实验性功能,将来可能会发生变化。
导入保护(Import Protection)用于防止仅服务端代码泄漏进客户端打包产物,也防止仅客户端代码泄漏进服务端打包产物。它运行在 TanStack Start 内部,默认开启。
工作原理
TanStack Start 会为客户端和服务端两种环境构建你的应用。有些代码只应在一种环境中运行。导入保护会在开发和生产构建期间检查源文件中的每一个导入,并阻止或模拟(mock)跨越环境边界的导入。
导入可能因两种方式被拒绝:
- 文件模式(file patterns):根据解析后的文件路径匹配。默认情况下,
*.server.*文件在客户端环境中被拒绝,*.client.*文件在服务端环境中被拒绝。 - 说明符模式(specifier patterns):根据原始导入字符串匹配。默认情况下,
@tanstack/react-start/server在客户端环境中被拒绝。
默认规则
导入保护开箱即用,默认配置如下:
| 设置 | 默认值 |
|---|---|
behavior(开发) | 'mock'——警告并替换为 mock 模块 |
behavior(构建) | 'error'——构建失败 |
log | 'once'——对重复违规去重 |
| 作用范围 | Start srcDirectory 内的文件 |
客户端环境拒绝规则:
- 匹配
**/*.server.*的文件 - 说明符
@tanstack/react-start/server - 文件检查中排除:
**/node_modules/**
服务端环境拒绝规则:
- 匹配
**/*.client.*的文件 - 文件检查中排除:
**/node_modules/**
默认情况下,node_modules 内的文件会通过 excludeFiles 选项从解析目标的拒绝检查中排除。这样可以避免第三方包的分发文件名恰好包含 .client. 或 .server. 时产生误报。如果你需要检查第三方文件,可以在相应环境上设置 excludeFiles: []——见配置拒绝规则。
这些默认值意味着你无需任何配置,就可以用 .server.ts / .client.ts 命名约定把文件限制在单一环境中。如果想同时拒绝整个目录(比如 server/ 或 client/),在拒绝规则配置的 files 中添加即可——比如客户端环境用 files: ['**/*.server.*', '**/server/**']。
仅类型导入(Type-Only Imports)
仅类型导入和再导出会被导入保护忽略,因为它们在运行时打包产物中被擦除了,不可能泄漏环境专属代码。
import type { User } from './db.server'
import { type RequestHandler } from '@tanstack/react-start/server'
export type { User } from './db.server'混合导入(只要包含至少一个运行时值)仍然会被检查。如果只有类型可以安全地跨环境边界,就把类型导入和值导入分开写。
// This is still checked because `getUsers` is a runtime value.
import { type User, getUsers } from './db.server'文件标记(File Markers)
你可以通过在文件顶部加一个副作用导入,把模块显式标记为仅服务端或仅客户端:
// src/lib/secrets.ts
import '@tanstack/react-start/server-only'
export const API_KEY = process.env.API_KEY// src/lib/local-storage.ts
import '@tanstack/react-start/client-only'
export function savePreferences(prefs: Record<string, string>) {
localStorage.setItem('prefs', JSON.stringify(prefs))
}当插件看到标记导入时,会把这个文件记录为受限文件。如果该文件之后被错误的环境导入,导入就会被拒绝。同一文件中同时出现两个标记始终是错误。
当文件不遵循 .server.* / .client.* 命名约定,但确实包含环境专属代码时,标记非常有用。
行为模式
behavior 选项控制检测到违规时会发生什么:
'error'——构建失败并给出详细的错误信息。这是生产构建的默认值。'mock'——导入被替换成一个返回安全代理值(proxy value)的 mock 模块。会记录一条警告,但构建继续。这是开发期间的默认值。
Mock 模式在开发时很有用,因为即使你的导入图存在违规,你也可以继续工作。mock 模块返回一个递归 Proxy,因此对 mock 导入的任何属性访问或函数调用都会返回另一个 mock,而不会崩溃。
你可以覆盖默认值:
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
export default defineConfig({
plugins: [
tanstackStart({
importProtection: {
// Always error, even in dev
behavior: 'error',
},
}),
],
})或者为不同模式设置不同行为:
importProtection: {
behavior: {
dev: 'mock',
build: 'error',
},
}配置拒绝规则
你可以在默认规则之上添加自己的拒绝规则。规则按环境用 glob 模式(通过 picomatch)或正则表达式指定。
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
export default defineConfig({
plugins: [
tanstackStart({
importProtection: {
client: {
// Block specific npm packages from the client bundle
specifiers: ['@prisma/client', 'bcrypt'],
// Block files in a custom directory
files: ['**/db/**'],
},
server: {
// Block browser-only libraries from the server
specifiers: ['localforage'],
},
},
}),
],
})检查第三方包
默认情况下,node_modules 内的已解析文件会从解析目标的拒绝检查(文件模式检查与标记检查)中排除。这样可以避免某些包的分发文件名恰好带 .client. 或 .server. 而产生误报。如果你要为特定环境重新启用检查,把 excludeFiles 设为空数组即可:
importProtection: {
server: {
// Re-enable file-pattern checking for node_modules in the server environment
excludeFiles: [],
},
}当你提供 excludeFiles 时,它会完全替换默认值(['**/node_modules/**'])。如果你想在仍跳过 node_modules 的同时排除其他路径,就把两者都写上:
importProtection: {
client: {
excludeFiles: ['**/node_modules/**', '**/vendor/**'],
},
}作用范围与排除
默认情况下,导入保护只检查 Start srcDirectory 内的文件。你可以用 include、exclude 和 ignoreImporters 修改作用范围:
importProtection: {
// Only check files matching these patterns
include: ['src/**'],
// Skip checking these files
exclude: ['src/generated/**'],
// Ignore violations when these files are the importer
ignoreImporters: ['**/*.test.ts', '**/*.spec.ts'],
}阅读违规追踪(Violation Traces)
检测到违规时,插件会显示一条诊断信息,包含导致违规的完整导入链、高亮违规行的代码片段,以及可操作的建议。
客户端中的仅服务端代码
下面这个例子展示了一个 *.server.* 文件经由中间模块被间接导入客户端环境:
[import-protection] Import denied in client environment
Denied by file pattern: **/*.server.*
Importer: src/features/auth/session.ts:5:27
Import: "../db/queries.server"
Resolved: src/db/queries.server.ts
Trace:
1. src/routes/index.tsx:2:34 (entry) (import "../features/auth/session")
2. src/features/auth/session.ts:5:27 (import "../db/queries.server")
Code:
3 | import { logger } from '../utils/logger'
4 |
> 5 | import { getUsers } from '../db/queries.server'
| ^
6 |
7 | export function loadAuth() {
src/features/auth/session.ts:5:27
Suggestions:
- Wrap in createServerFn().handler(() => ...) to make it callable from the client via RPC
- Wrap in createServerOnlyFn(() => ...) if it should not be callable from the client
- Use createIsomorphicFn().client(() => ...).server(() => ...) for environment-specific implementations
- Split the file so client-safe exports are separate服务端中的仅客户端代码
下面这个例子展示了一个 *.client.* 文件在 SSR 环境中被导入。因为代码片段包含 JSX,所以 <ClientOnly> 建议会优先显示:
[import-protection] Import denied in server environment
Denied by file pattern: **/*.client.*
Importer: src/components/dashboard.tsx:3:30
Import: "./browser-widget.client"
Resolved: src/components/browser-widget.client.tsx
Trace:
1. src/routes/dashboard.tsx:1:32 (entry) (import "../components/dashboard")
2. src/components/dashboard.tsx:3:30 (import "./browser-widget.client")
Code:
1 | import { BrowserWidget } from './browser-widget.client'
2 |
> 3 | export function Dashboard() { return <BrowserWidget /> }
| ^
4 |
src/components/dashboard.tsx:3:30
Suggestions:
- Wrap in <ClientOnly fallback={...}>...</ClientOnly> to render only after hydration
- Wrap in createClientOnlyFn(() => ...) if it should only run in the browser
- Use createIsomorphicFn().client(() => ...).server(() => ...) for environment-specific implementations
- Split the file so server-safe exports are separate如何阅读输出
每条违规信息包含以下部分:
| 部分 | 说明 |
|---|---|
| Header | 违规发生的环境类型("client" 或 "server") |
| Denied by | 命中的规则:文件模式、说明符模式或标记 |
| Importer / Import / Resolved | 导入文件(带 file:line:col)、原始导入字符串和解析后的目标路径 |
| Trace | 从入口点到被拒绝导入的完整导入链。每一步显示 file:line:col 和使用的导入说明符。第 1 步总是入口点 |
| Code | 带 > 标记违规行、用 ^ 指向精确列的源码片段 |
| Suggestions | 可操作的修复步骤,按违规方向(客户端中导入服务端代码 vs 服务端中导入客户端代码)分别定制 |
追踪从上到下阅读,从入口点到被拒绝的模块。这能帮你找到链的起点,从而重构代码。
常见坑:为什么有些导入仍然「活着」
有时候看起来 Start「本应该移除那个仅服务端导入」。关键细节在于这是由 Start 编译器处理的:
- 编译器为当前目标(客户端或服务端)重写环境专属的_实现_。
- 作为编译的一部分,它会剪枝代码,移除在重写后不再使用的导入。
实际上,当编译器把 createServerFn() 的 handler 替换成客户端 RPC 桩(stub)时,它也能移除那些仅被替换后的实现所使用的服务端导入。
示例(客户端构建):
import { getUsers } from './db/queries.server'
import { createServerFn } from '@tanstack/react-start'
export const fetchUsers = createServerFn().handler(async () => {
return getUsers()
})从概念上讲,客户端构建输出会变成类似这样(简化版):
import { createClientRpc } from '@tanstack/react-start/client-rpc'
import { createServerFn } from '@tanstack/react-start'
// Compiler replaces the handler with a client RPC stub.
// (The id is generated by the compiler; treat it as an opaque identifier.)
export const fetchUsers = TanStackStart.createServerFn({
method: 'GET',
}).handler(createClientRpc('sha256:deadbeef...'))
// The server-only import is removed by the compiler.如果导入「泄漏」到了编译后仍然存活的代码中,它就会保持活跃,导入保护依然会标记它:
import { getUsers } from './db/queries.server'
import { createServerFn } from '@tanstack/react-start'
// This is fine -- the server implementation is removed for the client build
export const fetchUsers = createServerFn().handler(async () => {
return getUsers()
})
// This keeps the import alive in the client build
export function leakyHelper() {
return getUsers() // referenced outside server boundary
}发生这种情况时,根据你想让 leakyHelper 变成什么样,有几种选择:
方案 A:拆分文件,让客户端代码不可能意外导入这个泄漏点
// src/users.server.ts
import { getUsers } from './db/queries.server'
import { createServerFn } from '@tanstack/react-start'
// Safe to import from client code (compiler rewrites the handler)
export const fetchUsers = createServerFn().handler(async () => {
return getUsers()
})// src/users-leaky.server.ts
import { getUsers } from './db/queries.server'
// Server-only helper; do not import this from client code
export function leakyHelper() {
return getUsers()
}方案 B:保留在同一个文件中,但把辅助函数包在 createServerOnlyFn 里
当辅助函数应当存在、但绝不能在客户端运行时,这个方案很有用。确保仅服务端导入只被 createServerOnlyFn(() => ...) 回调内部引用:
import { createServerOnlyFn } from '@tanstack/react-start'
import { getUsers } from './db/queries.server'
export const leakyHelper = createServerOnlyFn(() => {
return getUsers()
})在客户端,编译输出实际上是:
export const leakyHelper = () => {
throw new Error(
'createServerOnlyFn() functions can only be called on the server!',
)
}注意,createServerOnlyFn 的导入消失了,仅服务端的 getUsers 导入也因为编译后不再被引用而消失了。
同样的思路也适用于 createIsomorphicFn():编译器会移除非目标环境的实现,并剪枝一切不再使用的东西。
如果你发现某个文件出现了导入保护违规,而你认为它应该被「编译掉」,请检查该导入是否在编译器识别的环境边界之外被引用,或由编译后仍存活的代码保持其活跃。
误报:开发 vs 构建
在构建模式下,导入保护会把违规检查推迟到 tree-shaking 之后。如果某个导入从最终打包产物中被消除,就不会报告违规。构建时的违规是确凿的:如果构建标记了它,说明这个导入确实存活了。
在开发模式下,tree-shaking 可能不完整或不可用,因此导入保护可能对后面会被编译掉的导入报告误报。
如果警告出现在开发中但构建中没有,那通常意味着不安全的导入引用在 tree-shaking 之前存在,但没有在最终打包产物中存活。即便如此,更好的做法仍然是改变导入结构,让这类引用一开始就不存在。
混合桶文件(Barrels)与拆分入口点
桶文件(barrel,即集中再导出文件)本身不算误报。有风险的模式是:同一个入口点里,把安全导出与环境受限导出混在一起。
根据编译和 tree-shaking 后存活的内容,这可能表现为真正的违规,也可能只是开发期的误报。更安全的做法是把安全导出与受限导出拆到不同的入口点。
比如,避免这种混合桶:
// src/lib/index.ts
export { fetchUsers } from './fetchUsers'
export { getDb } from './db.server'// src/routes/users.tsx
import { fetchUsers } from '../lib'即使客户端只用 fetchUsers,这条导入路径仍然经过一个再导出了 getDb 的模块。
更好的做法是把安全导出和仅服务端导出拆到不同的入口点:
// src/lib/index.ts
export { fetchUsers } from './fetchUsers'// src/lib/server.ts
export { getDb } from './db.server'// src/routes/users.tsx
import { fetchUsers } from '../lib'// src/server/worker.ts
import { getDb } from '../lib/server'这也适用于带标记保护的文件(import '@tanstack/react-start/server-only')。如果带标记的文件通过混合桶再导出、但从未被客户端代码消费,生产构建在 tree-shaking 后可能会抑制警告,但更好的修复方式仍然是彻底避免把那个仅服务端的引用暴露给客户端可达的代码。
onViolation 回调
你可以订阅违规事件(hook),用于自定义上报或覆盖判定:
importProtection: {
onViolation: async (info) => {
// info.env -- environment name (e.g. 'client', 'ssr', ...)
// info.envType -- 'client' or 'server'
// info.type -- 'specifier', 'file', or 'marker'
// info.specifier -- the raw import string
// info.importer -- absolute path of the importing file
// info.resolved -- absolute path of the resolved target (if available)
// info.trace -- array of { file, line?, column?, specifier? } objects
// info.snippet -- { lines, highlightLine, location } with the source code snippet (if available)
// Return false (or Promise<false>) to allow this specific import (override the denial)
if (info.specifier === 'some-special-case') {
return false
}
},
}禁用导入保护
要完全禁用导入保护:
importProtection: {
enabled: false,
}完整配置参考
interface ImportProtectionOptions {
enabled?: boolean
behavior?:
| 'error'
| 'mock'
| { dev?: 'error' | 'mock'; build?: 'error' | 'mock' }
log?: 'once' | 'always'
include?: Array<string | RegExp>
exclude?: Array<string | RegExp>
ignoreImporters?: Array<string | RegExp>
maxTraceDepth?: number
client?: {
specifiers?: Array<string | RegExp>
files?: Array<string | RegExp>
excludeFiles?: Array<string | RegExp>
}
server?: {
specifiers?: Array<string | RegExp>
files?: Array<string | RegExp>
excludeFiles?: Array<string | RegExp>
}
onViolation?: (
info: ViolationInfo,
) => boolean | void | Promise<boolean | void>
}| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
enabled | boolean | true | 设为 false 禁用插件 |
behavior | string | object | { dev: 'mock', build: 'error' } | 检测到违规时怎么办 |
log | 'once' | 'always' | 'once' | 是否对重复违规去重 |
include | Pattern[] | Start 的 srcDirectory | 只检查匹配这些模式的导入方 |
exclude | Pattern[] | [] | 跳过匹配这些模式的导入方 |
ignoreImporters | Pattern[] | [] | 忽略来自这些导入方的违规 |
maxTraceDepth | number | 20 | 导入追踪的最大深度 |
client | object | 见上面的默认值 | 客户端环境的附加拒绝规则 |
client.specifiers | Pattern[] | 框架服务端说明符 | 客户端环境中被拒绝的说明符模式(与默认值相加) |
client.files | Pattern[] | ['**/*.server.*'] | 客户端环境中被拒绝的文件模式(替换默认值) |
client.excludeFiles | Pattern[] | ['**/node_modules/**'] | 匹配这些模式的已解析文件跳过解析目标检查(文件模式 + 标记)(替换默认值) |
server | object | 见上面的默认值 | 服务端环境的附加拒绝规则 |
server.specifiers | Pattern[] | [] | 服务端环境中被拒绝的说明符模式(替换默认值;server.specifiers 的默认值是 [],因此与 client.specifiers 不同,它不是累加的) |
server.files | Pattern[] | ['**/*.client.*'] | 服务端环境中被拒绝的文件模式(替换默认值) |
server.excludeFiles | Pattern[] | ['**/node_modules/**'] | 匹配这些模式的已解析文件跳过解析目标检查(文件模式 + 标记)(替换默认值) |
onViolation | function | undefined | 每次违规时调用的回调 |