外观
HTTP
HTTP 用配置树声明业务接口,并统一处理 baseURL、headers、timeout、retry、拦截器、错误归一化和响应解包。配置本身就是契约,不需要再维护一份独立的 API 类型树。
基础使用
ts
import { createHttp } from '@freenotes/web-runtime';
interface Book {
id: string;
name: string;
price: number;
}
interface BookInput {
name: string;
price: number;
}
const http = createHttp({
baseURL: '/api',
headers: {
'client-source': 'web'
},
timeoutMs: 15000,
retry: {
maxRetries: 2,
delayMs: 1000
}
});
export const api = http.createApi((contract) => ({
books: {
get: {
method: 'get',
url: '/books/:id',
path: contract.type<{ id: string }>(),
query: contract.type<{ includeDeleted?: boolean }>(),
response: contract.type<Book>()
},
create: {
method: 'post',
url: '/books',
data: contract.type<BookInput>(),
response: contract.type<Book>()
}
}
}));method 会保留字符串字面量,不需要写 as const。path、query、data 和 response 是编译期类型标记,不会参与请求序列化,也不会要求业务额外创建运行时 schema。
调用时会直接得到输入补全和返回值类型:
ts
const book = await api.books.get({
path: { id: 'book-1' },
query: { includeDeleted: false },
options: { timeoutMs: 3000 }
});
await api.books.create({
data: { name: 'TypeScript', price: 99 }
});当 query 的所有字段都是可选字段时,整个 query 也可以省略;声明了必填 path 或 data 时,调用参数会相应变为必填。即使未声明 path 契约,字面量 URL 中的 :name 也会被推导为必填 path 字段;显式声明 path 时,其 output 必须包含 URL 的全部占位符。完全未声明类型标记的配置仍可用于纯运行时场景,返回值为 unknown,其余调用字段降级为通用 HttpCallInput。
使用 Zod 等 Schema
path、query、data 和 response 也可以直接接收实现 Standard Schema V1 的 schema。HTTP 不依赖 Zod;业务项目可以自行选择 Zod 或其他兼容库。
ts
import { z } from 'zod';
import { createHttp } from '@freenotes/web-runtime';
const http = createHttp({ baseURL: '/api' });
export const api = http.createApi({
books: {
get: {
method: 'get',
url: '/books/:id',
path: z.object({
id: z.string()
}),
query: z.object({
includeDeleted: z.boolean().optional()
}),
response: z.object({
id: z.string(),
title: z.string()
})
},
create: {
method: 'post',
url: '/books',
data: z.object({
title: z.string().trim(),
price: z.coerce.number()
}),
response: z.object({
id: z.string(),
title: z.string(),
price: z.number()
})
}
}
});Schema 的 input 类型决定调用参数,output 类型决定转换后的请求值或响应返回值。因此上例 price 可以接收 Zod coercion 支持的输入,实际发送前会转换为 number。
当 schema 将 input 声明为 unknown 时,HTTP 不会据此假定该字段允许省略;业务仍需显式传入字段。只有 schema input 明确包含 undefined,或 query 的对象字段全部可选时,调用字段才会变为可选。
校验顺序如下:
path、query和data在 URL 构造与transformRequest之前校验;- schema 的 coercion/transform output 进入实际请求;
response在transformResponse、response interceptor 和业务 envelope 解包之后校验;- 请求校验失败抛出
ERR_HTTP_REQUEST_VALIDATION,响应校验失败抛出ERR_HTTP_RESPONSE_VALIDATION,具体问题保存在HttpError.issues。
如果只需要编译期类型、不希望引入运行时校验成本,继续使用 contract.type<T>()。
请求输入
endpoint 方法统一接收一个对象:
ts
interface HttpCallInput {
path?: Record<string, string | number | boolean>;
query?: Readonly<Record<string, unknown>>;
data?: unknown;
options?: {
baseURL?: string;
headers?: Record<string, string>;
decodeResponse?: HttpResponseDecoder | false;
responseType?: 'arraybuffer' | 'blob' | 'json' | 'text';
signal?: AbortSignal;
timeoutMs?: number;
};
}path 替换 URL 中的 :name,并通过 encodeURIComponent 编码;query 映射为 URL 查询参数;data 作为 request body;options 只覆盖当前调用。HTTP 不根据 method 自动丢弃 query 或 data。
全局配置、endpoint 配置、调用级 options 和 transformRequest 的结果在 request interceptor 之前合并。拦截器可以删除最终请求中的 header,例如 delete request.headers.Authorization;发送时不会重新补回 runtime 默认 header,也不会影响后续请求的默认配置。
响应与错误
HTTP 默认保留响应体,不解释普通 code 或 ok 字段。需要业务 envelope 时,在 client 上显式配置:
ts
import { createHttp, unwrapBusinessEnvelope } from '@freenotes/web-runtime';
const http = createHttp({ baseURL: '/api', decodeResponse: unwrapBusinessEnvelope });该预设识别以下格式并返回其中的 data:
json
{
"code": 0,
"data": {},
"message": "success"
}0、200、"0" 和 "200" 表示成功;boolean ok 优先。失败抛出 kind: "business" 的 HttpError,业务码保存在 businessCode。非 envelope 数据按原值返回。endpoint 或单次调用可用 decodeResponse: false 禁用 client 解包,也可提供自定义 decoder。
decodeResponse(response, context) 在 endpoint transform 和 response interceptor 之后、响应 schema 校验之前运行。它可以是异步函数。
HttpError.kind 区分 definition、request-validation、response-validation、transport、business、aborted 和 processing。code 仅保存 runtime 错误码;transportCode 保存 axios 原始代码,如 ERR_CANCELED、ECONNABORTED 和 ERR_NETWORK;businessCode 仅由显式业务协议 decoder 填充。HTTP 非 2xx 失败不自动解释响应体中的业务字段,原始 body 保留在 error.response.data。
请求与响应转换
特殊协议可以使用 endpoint 的 transformRequest 和 transformResponse:
ts
const transportApi = http.createApi((contract) => ({
submit: {
method: 'post',
url: '/transport/:scope',
path: contract.type<{ scope: string }>(),
data: contract.type<{ token: string }>(),
response: contract.type<{ ok: boolean }>(),
transformRequest({ input }) {
return {
data: encryptPayload(input.data),
headers: { 'Content-Type': 'application/octet-stream' },
responseType: 'text'
};
},
transformResponse(value) {
return decryptPayload(String(value));
}
}
}));转换函数只负责当前 endpoint。认证头、trace id、统一日志和错误上报等通用逻辑应放到 createHttp({ interceptors })。
Retry
默认关闭重试(maxRetries: 0)。显式设置 maxRetries > 0 后,默认仅允许重试 get、head 和 options,避免自动重放有副作用的写请求。maxRetries 不包含首次请求,例如 1 表示最多请求两次。
默认可重试 transport code 包括 ECONNABORTED、ENETUNREACH、ENOTFOUND、ERR_NETWORK 和 ETIMEDOUT。如写接口通过幂等键保证可安全重放,可显式配置:
ts
const http = createHttp({
retry: {
maxRetries: 1,
retryableMethods: ['get', 'post'],
retryableStatusCodes: [408, 500, 502, 503, 504]
}
});默认重试状态码为 408、500、502、503、504,初始间隔为 1000ms,退避倍数为 2,单次等待上限为 30000ms。只有方法及失败条件符合配置时才会重试,取消请求不会触发重试。
为什么没有 request
http.request() 不作为公开 API。业务请求集中通过 createApi() 声明,才能维持统一接口契约、类型补全和迁移边界。底层 axios 只是内部 adapter,不进入公开类型。