@longzai-intelligence-typescript/rolldown-plugin-type-boundary
v0.1.26
Published
构建期 rolldown 插件,把 schema 派生(omit/pick/extend)展开为独立 z.object 字面量,阻断字段名向 JS bundle 泄漏
Maintainers
Readme
@longzai-intelligence-typescript/rolldown-plugin-type-boundary
构建期 rolldown 插件,把 schema / 类型派生在打包阶段展开为独立字面量,阻断被删除的字段名向产物泄漏。
解决的问题
schema 派生(omit/pick/extend)与 TS 原生类型派生(Omit<T,'x'> / Pick<T,'x'>)会让被删除的字段名以字面量形态残留在构建产物中,即便字段在类型层与运行时确已移除。泄漏分两条通道:
| 通道 | 派生写法 | 残留形态 | 后果 |
| --------- | ------------------------------ | ---------------------------------- | ------------------------------------ |
| JS bundle | Schema.omit({ phone: true }) | omit({ phone: true }) 实参硬编码 | 敏感字段名出现在发布的 bundle 源码中 |
| .d.ts | type B = Omit<A, 'phone'> | Omit<A, 'phone'> 字面量 | 敏感字段名出现在发布的类型声明中 |
本插件在构建期对源 .ts 做静态重写,把派生表达式展开为扁平字面量,并切断对源定义的引用,让被删字段名从产物中彻底消失——同时保持开发态推断链连通(开发体验不变)。
作用范围
在构建期(transform order:'pre')识别并改写以下两类派生:
通道 1:JS bundle(zod 派生)
识别 X.omit({...}) / X.pick({...}) / X.extend({...})(X 为 zod ZodObject),静态重写为独立 z.object({...}) 字面量。
// 改写前(源码,开发态保持推断链连通)
const SourceSchema = z.object({ id: z.number(), phone: z.string(), name: z.string() });
export const PublicUserSchema = SourceSchema.omit({ phone: true });
// 改写后(JS 产物)
export const PublicUserSchema = z.object({
id: z.number(),
name: z.string(),
});源 schema 断链后若不再被引用,其声明语句一并删除(敏感字段名随之消失)。
通道 2:.d.ts(TS 原生类型派生)
识别 type B = Omit<A, 'x'> / type B = Pick<A, 'x'>,展开为 inline 对象类型,消除 Omit< 与字段名字面量。
// 改写前(源码)
type A = { phone: string; name: string; id: number };
type B = Omit<A, 'phone'>;
// 改写后(产物)
type B = {
name: string;
id: number;
};支持源类型为 type alias 指向 TSTypeLiteral、interface 两种形态。
源定义的位置
两条通道均支持:
- 同文件定义:源 schema / 源类型与派生在同一文件内
- 跨文件定义:源通过
import引入(基于 rolldown 的this.resolve+ 文件读取,自动解析)
用法
作为 rolldown / lzi-builder 插件,注入构建配置的 plugins 字段:
// lzi-builder.config.ts(或 rolldown.config.ts)
import { defineConfig } from '@longzai-intelligence-builder/esm';
import { typeBoundaryPlugin } from '@longzai-intelligence-typescript/rolldown-plugin-type-boundary';
export default defineConfig({
plugins: [typeBoundaryPlugin()],
});配置项
export interface TypeBoundaryOptions {
/** 全局开关,默认 true */
enabled?: boolean;
/** 命中派生但无法静态重写时的策略,默认 'warn' */
onUnsupported?: 'warn' | 'error' | 'silent';
/** 强制保护的字段名/模式清单(敏感字段意图登记,CI 守门) */
protect?: Array<string | RegExp>;
/** 是否启用 .d.ts 通道(TS 原生 Omit/Pick 改写),默认 true */
dts?: boolean;
/** 是否打印改写日志 */
debug?: boolean;
}| 选项 | 说明 |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| enabled | 全局开关,false 时插件直接返回不处理 |
| onUnsupported | 命中派生但因能力边界无法静态重写时的策略:warn(默认,输出不阻断)、error(抛错阻断构建,CI 守门用)、silent(静默,不推荐,易造成假阳性安全感) |
| protect | 敏感字段意图清单。声明后:成功断链中被删字段命中清单时输出确认日志;存在 unsupported 派生时,无视 onUnsupported 一律升级为 error(敏感字段不允许静默泄漏) |
| dts | 是否启用 .d.ts 通道,默认 true |
| debug | 打印每个命中文件的改写原因与替换文本 |
典型配置
// 严格守门场景:敏感字段登记 + 不可解析派生直接失败
typeBoundaryPlugin({
protect: ['password', 'phone', 'idCard', /secret/i],
onUnsupported: 'error',
});能力边界
当前版本支持与不支持:
支持
- zod
omit/pick/extend三种派生(单次调用) - TS 原生
Omit<T,K>/Pick<T,K>类型派生(含联合类型 key'a' | 'b') - 同文件源定义(
z.object字面量 /type alias/interface) - 跨文件源定义(基于
import解析) export const/export type包裹的派生
不支持(命中时按 onUnsupported 处理,默认 warn)
- zod 链式多次派生
S.omit({}).omit({}).extend({}) - zod
partial/merge/intersection/discriminatedUnion/refine/transform - TS 原生
Omit链的嵌套展开(如Omit<Omit<A,'x'>,'y'>,当前只展开最外层) - 源 schema 经工厂函数 /
as const/ 变量赋值生成 - 嵌套对象字段级 omit
不支持的场景不会静默放行:会被收集为 unsupported 记录,按 onUnsupported 策略 warn / error。这是本插件的安全设计——避免开发者误以为已断链实则未断。
原理
- transform
order:'pre'拦截源.ts,取 rolldown 提供的meta.ast(oxc Program) - 粗筛:文件是否含 zod 派生调用 / TS 类型派生
- 遍历变量声明与类型别名,匹配派生表达式
- 静态解析源定义(同文件直接查找;跨文件经
this.resolve+ 文件读取 + oxc parse) - 计算派生后字段集合,用
meta.magicString.overwrite重写为扁平字面量 - 同文件源定义断链后若不再被引用,删除其声明语句
跨文件解析带缓存(同一 import 说明符只读盘解析一次)。
测试
bun test覆盖 zod 派生、TS 原生类型派生、跨文件解析、能力边界契约等场景。
依赖
rolldown(peerDependencies,构建期运行,要求^1.2.0)- 不依赖 zod(仅做 AST 静态分析,不 import zod 运行时;对消费方使用的 zod 版本无要求,语义上仅对 zod v4 有意义)
