npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

secure-crypto-top-zod-sdk

v1.0.8

Published

🔐 端到端加密 SDK - 融合版:多协议(custom/TOP) + 多算法(AES-GCM/SM4/SM3/MD5/RSA-OAEP) + zod 校验 + 浏览器零 polyfill(Web Crypto 原生)

Readme

🔐 secure-crypto-top-zod-sdk

融合版端到端加密 SDK:多协议(custom / TOP / binary) + 多算法(AES-GCM/CBC/SM4/SM3/MD5/SHA-512/RSA-OAEP) + zod 校验 + 浏览器零 polyfill(Web Crypto 原生) + 分片上传 v1.3.0(秒传 / 断点续传 / 进度 / 取消,单文件 5GB) + 智能上传 v1.4.0(SmartClient 唯一上传入口,任意大小/任意数量文件,服务端智能分流 ONE_SHOT / MULTIPART) + 信封走私防护 v1.0.4

⚠️ v1.4.x 方案 A 清理:requestBinary() 方法与 POST /upload/{private,public,batch} 三个老端点已删除,业务统一用 SmartClient.upload()binary 协议与 MultipartClient 仍保留(SmartClient 内部复用)。 吸收了 secure-crypto-sdk(老)和 secure-crypto-top-sdk(新)的所有优点

npm version license


✨ 融合特性一览

| 维度 | 旧 secure-crypto-sdk | 旧 secure-crypto-top-sdk | secure-crypto-top-zod-sdk v1.0.4 | |------|------------------------|---------------------------|------------------------------------------| | 协议 | 1 个 (custom) | 2 个 (custom + top) | ✅ 3 个(custom + top + binary) | | 加密算法 | AES-256-GCM | AES-GCM/CBC/SM4/RSA-OAEP | ✅ 5 种 全保留 | | 摘要算法 | HMAC-SHA256 | HMAC-SHA256/512/MD5/SM3 | ✅ 6 种 全保留(新增 sha256/sha512 浏览器原生) | | 分片上传 | ❌ | ❌ | ✅ v1.0.3 新增:MultipartClient + 5 端点 + 秒传/续传/重试/进度/取消 | | 智能上传 (Smart) | ❌ | ❌ | ✅ v1.4.0 新增:SmartClient 一行调用,唯一上传入口,服务端按 totalSize 智能分流 ONE_SHOT(≤1MB)/ MULTIPART(1MB/5MB/10MB 动态 partSize),后续 part/complete 复用 multipart/*。v1.0.8+ maxFilesPerBatch 可配(默认 50) | | 批量上传 v1.2.0(POST /upload/batch) | ❌ | ❌ | ⚠️ v1.4.x 方案 A 已删除,老代码请迁 SmartClient.upload() | | requestBinary() 单文件 binary 上传 | ❌ | ❌ | ⚠️ v1.4.x 方案 A 已删除,内部分片/任意大小文件用 MultipartClientSmartClient | | 信封走私防护 | ❌ | ❌ | ✅ v1.0.4 新增:envelope.method/http_method 强校验 + IP 自动封禁 + fail closed | | 多 app 隔离 | ❌ | ✅ | ✅ 保留 | | 浏览器运行 | ✅ Web Crypto 原生(零依赖) | ❌ 需 crypto-browserify polyfill | ✅ 零 polyfill(Web Crypto) | | 浏览器 bundle | ~50KB | ~1.4MB (带 polyfill) | ✅ ~232KB(自包含,无 polyfill,含分片 + 智能上传 + v1.0.8 可配批量上限) | | 启动校验密钥 | ✅ | ❌ | ✅ 融合双方 | | zod 字段校验 | ✅ | ❌ | ✅ 融合 | | 响应验签 | ✅ | ❌ | ✅ 融合(verifyResponse: true 选项) | | 国密 SM4/SM3 | ❌ | ✅ | ✅ 保留(服务端用) | | Fastify 集成 | ✅ | ✅ | ✅ 保留 | | 跨运行时抽象 | ❌(直接 Web Crypto) | ❌(直接 node:crypto) | ✅ CryptoBackend 抽象层 | | 框架集成示例 | — | — | ✅ Vue 3 / React 18 一线集成示例 | | SSR / Edge 支持 | — | — | ✅ Nuxt 3 · Next.js · Cloudflare Workers BFF 示例 |


🚀 快速开始

服务端(Fastify)

import Fastify from 'fastify';
import cors from '@fastify/cors';
import secureCryptoPlugin from 'secure-crypto-top-zod-sdk/server';

const app = Fastify({ logger: true });
app.register(cors, { origin: true });

app.register(secureCryptoPlugin, {
  apps: [
    {
      appKey: 'demo-app',
      appSecret: 'demo-app-secret-1234567890abcdef',  // >= 32 字符
      allowedCiphers: ['aes-256-gcm', 'aes-256-cbc', 'sm4-cbc', 'rsa-oaep-aes-256-gcm'],
      allowedDigests: ['hmac-sha256', 'hmac-sha512', 'sm3'],
    },
  ],
  defaultProtocol: 'top',  // 或 'custom'
  bypassPaths: ['/health'],
});

// 启动后任何 app.post 都自动加密/解密
app.post('/api/v1/top/login', async (req) => {
  return { user: { id: 1, name: 'alice' }, biz: (req as any).secureRequest.biz };
});

app.listen({ port: 3000 });

客户端(浏览器 / Node)

import { SecureClient } from 'secure-crypto-top-zod-sdk/client';

const client = new SecureClient({
  baseURL: 'https://api.example.com',
  appKey: 'demo-app',
  // encryptionKey = 64 字符 hex(32 字节),用于 AES-256-GCM 加解密业务数据
  // 推荐:和服务端 appSecret 独立(多 app 隔离 / 密钥轮换更安全)
  encryptionKey: 'a'.repeat(64),                 // ★ 32 字节对称密钥,不要和 appSecret 混用
  appSecret: 'demo-app-secret-1234567890abcdef',  // ★ 0.2.x 推荐字段名(兼容旧 hmacSecret)
  protocol: 'top',                                // 或 'custom'
  cipher: 'aes-256-gcm',
  digest: 'hmac-sha256',
  // 融合后新增(可选):
  verifyResponse: true,  // 是否验签响应(需要客户端持有 secret)
});

const result = await client.post('/api/v1/top/login', {
  username: 'alice',
  password: 's3cret',
});

字段命名:0.2.x 推荐使用 appSecret,老字段 hmacSecret 仍兼容;响应验签可选独立 responseAppSecret(兼容旧 responseHmacSecret)。


🧩 框架集成(Vue 3 / React 18)

下面给两个最常见的 SPA 框架的"开箱即用"示例。核心思路相同:

  1. 单例 SecureClient(全局只 new 一次,避免重复握手/派生密钥)
  2. 拦截器层面统一处理加签/解密,业务代码完全无感
  3. 错误SecureError,UI 层 toast 即可
  4. 配合后端 secureCryptoPlugin 一行 register 即可

⚡ Vue 3(Composition API + Axios 拦截器)

适合:Vue 3 + Vite + Pinia + Axios 项目。

1. src/lib/secure-client.ts —— 单例

import { SecureClient, SecureError } from 'secure-crypto-top-zod-sdk/client';

// ⚠️ 浏览器场景:appSecret 暴露在前端,务必配合后端风控(短 token、IP 限流、签名有效期)
export const secureClient = new SecureClient({
  baseURL: import.meta.env.VITE_API_BASE ?? 'https://api.example.com',
  appKey: import.meta.env.VITE_APP_KEY ?? 'demo-app',
  // 推荐:把 appSecret 单独部署(下文有"为什么 secret 不能进 bundle"的说明)
  appSecret: import.meta.env.VITE_APP_SECRET ?? 'demo-app-secret-1234567890abcdef',
  // 64 字符 hex(= SHA256(appSecret)),如果不想算可以后端给一个预生成值
  encryptionKey: import.meta.env.VITE_ENCRYPTION_KEY ?? '',
  protocol: 'top',
  cipher: 'aes-256-gcm',
  digest: 'hmac-sha256',
  verifyResponse: true,
});

export { SecureError };

2. src/lib/api.ts —— 拦截器里改用 SecureClient

import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios';
import { secureClient, SecureError } from './secure-client';

export const http = axios.create({ baseURL: secureClient['baseURL'] });

// 请求拦截:把业务参数先交给 SecureClient 加密,再把 envelope 塞进 body
http.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
  const method = (config.method?.toUpperCase() ?? 'GET') as
    | 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
  const url = config.url ?? '/';

  // SecureClient 内部会发自己的 POST + 解析响应,所以走它自己的 request
  // 这里把 axios 适配成一个"加密通道":用 adapter 替换真正的网络层
  config.adapter = async (axiosConfig) => {
    try {
      const data = await secureClient.request(method, url, {
        body: axiosConfig.data,
        headers: axiosConfig.headers as Record<string, string>,
      });
      return {
        data,
        status: 200,
        statusText: 'OK',
        headers: {},
        config: axiosConfig,
      };
    } catch (e) {
      if (e instanceof SecureError) {
        throw new AxiosError(e.message, String(e.code), axiosConfig);
      }
      throw e;
    }
  };
  return config;
});

// 响应拦截:把业务错误统一抛
http.interceptors.response.use(
  (resp) => resp.data,
  (err: AxiosError) => {
    // 业务层 try/catch 即可,这里只打 log
    console.error('[API]', err.code, err.message);
    return Promise.reject(err);
  }
);

3. src/composables/useApi.ts —— 业务层 hook

import { ref } from 'vue';
import { http } from '@/lib/api';
import { SecureError } from '@/lib/secure-client';

export function useApi<TReq, TRes>(url: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' = 'POST') {
  const data = ref<TRes | null>(null);
  const error = ref<string | null>(null);
  const loading = ref(false);

  const run = async (body?: TReq) => {
    loading.value = true;
    error.value = null;
    try {
      const resp = await http.request<TRes>({ url, method, data: body });
      data.value = resp.data;
    } catch (e) {
      error.value = e instanceof SecureError ? e.message : String(e);
    } finally {
      loading.value = false;
    }
  };

  return { data, error, loading, run };
}

4. 组件里使用(<script setup>)

<script setup lang="ts">
import { useApi } from '@/composables/useApi';

interface LoginReq { username: string; password: string; }
interface LoginRes { user: { id: number; name: string }; token: string; }

const { data, error, loading, run } = useApi<LoginReq, LoginRes>('/api/v1/top/login', 'POST');

const submit = () => run({ username: 'alice', password: 's3cret' });
</script>

<template>
  <button :disabled="loading" @click="submit">登录</button>
  <p v-if="error" style="color:red">{{ error }}</p>
  <pre v-else-if="data">{{ data }}</pre>
</template>

5. 配合 Pinia 存 token

// src/stores/auth.ts
import { defineStore } from 'pinia';
import { http } from '@/lib/api';

export const useAuthStore = defineStore('auth', {
  state: () => ({ token: '' as string }),
  actions: {
    async login(username: string, password: string) {
      const data = await http.post('/api/v1/top/login', { username, password });
      this.token = (data.data as any).token;
    },
  },
});

⚛️ React 18(Hooks + 自定义 fetch 封装)

适合:React 18 + Vite/Webpack + Zustand/Redux 项目。

1. src/lib/secure-client.ts —— 单例

import { SecureClient, SecureError } from 'secure-crypto-top-zod-sdk/client';

export const secureClient = new SecureClient({
  baseURL: import.meta.env.VITE_API_BASE ?? 'https://api.example.com',
  appKey: import.meta.env.VITE_APP_KEY ?? 'demo-app',
  appSecret: import.meta.env.VITE_APP_SECRET ?? 'demo-app-secret-1234567890abcdef',
  encryptionKey: import.meta.env.VITE_ENCRYPTION_KEY ?? '',
  protocol: 'top',
  cipher: 'aes-256-gcm',
  digest: 'hmac-sha256',
  verifyResponse: true,
});

export { SecureError };

2. src/lib/api.ts —— 通用 apiFetch 封装

import { secureClient, SecureError } from './secure-client';

type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';

export async function apiFetch<T = unknown>(
  method: Method,
  path: string,
  body?: unknown,
  init?: RequestInit
): Promise<T> {
  try {
    return await secureClient.request<T>(method, path, {
      body,
      headers: init?.headers as Record<string, string>,
      signal: init?.signal,
    });
  } catch (e) {
    if (e instanceof SecureError) {
      // 业务错误(签名错 / 业务失败)统一抛
      const err = new Error(e.message);
      (err as any).code = e.code;
      throw err;
    }
    throw e;
  }
}

// 几个语法糖
export const api = {
  get:    <T>(p: string, init?: RequestInit) => apiFetch<T>('GET',    p, undefined, init),
  post:   <T>(p: string, b?: unknown, init?: RequestInit) => apiFetch<T>('POST',   p, b, init),
  put:    <T>(p: string, b?: unknown, init?: RequestInit) => apiFetch<T>('PUT',    p, b, init),
  delete: <T>(p: string, init?: RequestInit) => apiFetch<T>('DELETE', p, undefined, init),
  patch:  <T>(p: string, b?: unknown, init?: RequestInit) => apiFetch<T>('PATCH',  p, b, init),
};

3. src/hooks/useApi.ts —— 业务 hook

import { useCallback, useState } from 'react';
import { api } from '@/lib/api';

export function useApi<Req, Res>(method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', path: string) {
  const [data, setData] = useState<Res | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  const run = useCallback(async (body?: Req) => {
    setLoading(true);
    setError(null);
    try {
      const r = await api[method.toLowerCase() as 'get' | 'post' | 'put' | 'delete' | 'patch']<Res>(path, body as any);
      setData(r);
      return r;
    } catch (e: any) {
      setError(e?.message ?? String(e));
      throw e;
    } finally {
      setLoading(false);
    }
  }, [method, path]);

  return { data, error, loading, run };
}

4. 组件里使用

import { useApi } from '@/hooks/useApi';

interface LoginReq { username: string; password: string; }
interface LoginRes { user: { id: number; name: string }; token: string; }

export function LoginButton() {
  const { data, error, loading, run } = useApi<LoginReq, LoginRes>('POST', '/api/v1/top/login');

  return (
    <div>
      <button disabled={loading} onClick={() => run({ username: 'alice', password: 's3cret' })}>
        {loading ? '登录中...' : '登录'}
      </button>
      {error && <p style={{ color: 'red' }}>{error}</p>}
      {data  && <pre>{JSON.stringify(data, null, 2)}</pre>}
    </div>
  );
}

5. SWR / React Query 适配(可选)

如果你已经在用 @tanstack/react-query,只需把 queryFn / mutationFn 替换成 apiFetch 即可:

import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';

export function useLogin() {
  return useMutation({
    mutationFn: (req: LoginReq) => api.post<LoginRes>('/api/v1/top/login', req),
  });
}

export function useProfile() {
  return useQuery({
    queryKey: ['profile'],
    queryFn: () => api.get<{ id: number; name: string }>('/api/v1/top/profile'),
  });
}

🔐 "secret 在前端"是不是有问题?

是的,生产环境强烈不推荐appSecret 写进前端 bundle。本 SDK 的浏览器场景定位是:

  • 内网 / 后台管理系统(用户登录态本身就有 RBAC,加签是"防抓包/防重放")
  • 混合方案:secret 放在 BFF(Backend-For-Frontend)层,前端只调 BFF;BFF 再用本 SDK 的 Node 入口调真实业务

如果一定要放在浏览器:

  • 启用后端 nonce 短窗口 + IP 限流
  • 缩短 accessToken 有效期(配合 refresh)
  • 关闭 verifyResponse(浏览器拿到 secret 验签没意义,除非你愿意接受泄露)

简单说:secret 进 bundle = 等于明文 HTTPS。要真正的端到端,放 BFF。


📤 分片上传(Multipart Upload)v1.0.3 新增

单文件最大 5GB · 单片 5-50MB · 断点续传 · 秒传 · 并发可控 · 可取消 浏览器 / Node / RN / Web Worker 一套代码搞定

🎯 适用场景

| 场景 | 推荐 API | 备注 | |------|---------|------| | 单文件 > 200MB(单文件直传 bodyLimit 兜不住) | MultipartClient | 5GB 上限,按 part 切 | | 网络不稳,需要断点续传 | MultipartClient | 自动跳过已收到的 part | | 同一文件多人传,需要秒传 | MultipartClient | 整文件 SHA-256 命中即返回 | | 大文件要 UI 进度条 | MultipartClient + onProgress | 5 个 phase 都打点 | | 要中途取消 | MultipartClient + AbortController | 失败自动 abort 清理 | | 自定义传输层(已有 axios/fetch 封装) | 5 个低阶方法 | 自己控制每一步 |

🚀 两种使用方式

| 方式 | 适合 | 代码量 | 推荐度 | |------|------|--------|--------| | 高阶封装 MultipartClient | 90% 场景 | 一行 new ...upload(file) | ⭐⭐⭐⭐⭐ | | 低阶 5 方法 | 特殊定制(已有传输层/自定义并发) | ~30 行手写 | ⭐⭐⭐ |


✅ 方式一:MultipartClient(强烈推荐)

MultipartClient 把分片上传"应该是什么样"封装成一行 new MultipartClient(client).upload(file)

自动处理清单:

  • ✅ 整文件 SHA-256(用于秒传)
  • ✅ 断点续传(启动前先 status,跳过已收到的 part)
  • ✅ 并发上传(默认 3,可配 concurrency)
  • ✅ 失败重试(指数退避,默认 5 次,500ms 起步)
  • ✅ 智能重试(参数错 / 状态错 / 签名错不重试;网络错 / 限流 / 5xx 才重试)
  • ✅ 进度回调(onProgress({ bytesUploaded, partsDone, phase }))
  • ✅ 中途取消(AbortSignal)
  • ✅ 失败自动 abort(不留 uploadId 尸体)
  • ✅ 跨环境(浏览器 / Node / RN / Web Worker 一套代码,零运行时依赖)

浏览器 + <input type="file">

<input type="file" id="picker" />
<progress id="bar" max="100" value="0"></progress>
<pre id="out"></pre>
<script type="module">
  import { SecureClient, MultipartClient, fromBlob } from 'https://cdn.jsdelivr.net/npm/[email protected]/dist/client.mjs';

  const client = new SecureClient({
    baseURL: 'https://api.example.com',
    appKey: 'demo-app',
    appSecret: 'demo-app-secret-1234567890abcdef', // 浏览器场景见下文"secret 安全"
    encryptionKey: 'a'.repeat(64),                  // 32 字节对称密钥
    protocol: 'top',
    cipher: 'aes-256-gcm',
    digest: 'hmac-sha256',
  });

  const mc = new MultipartClient(client);

  document.getElementById('picker').addEventListener('change', async (e) => {
    const file = e.target.files[0];
    const bar = document.getElementById('bar');
    const out = document.getElementById('out');

    try {
      const result = await mc.upload(fromBlob(file), {
        isPublic: false,
        session: localStorage.getItem('jwt'),
        concurrency: 3,
        computePartHash: true,             // 让 server 校验每个 part
        onProgress: (p) => {
          // p: { bytesUploaded, totalBytes, partsDone, partsTotal, phase }
          // phase: 'hashing' | 'init' | 'uploading' | 'merging' | 'done'
          const pct = Math.min(100, (p.bytesUploaded / p.totalBytes) * 100);
          bar.value = pct;
          out.textContent = `[${p.phase}] ${pct.toFixed(1)}% (${p.partsDone}/${p.partsTotal} parts)`;
        },
      });

      out.textContent = `✅ 上传完成\n${JSON.stringify(result, null, 2)}`;
      // result = { fileId, filename, size, contentType, url, dedup, resumed, finalHash }
    } catch (e) {
      out.textContent = `❌ ${e.message}`;
    }
  });
</script>

Node + Buffer

import { SecureClient, MultipartClient, fromBuffer } from 'secure-crypto-top-zod-sdk';
import { promises as fs } from 'node:fs';

const client = new SecureClient({
  baseURL: 'http://localhost:8080',
  appKey: 'demo-app',
  appSecret: process.env.APP_SECRET!,
  encryptionKey: process.env.APP_ENCRYPTION_KEY_HEX!,
  protocol: 'top',
  cipher: 'aes-256-gcm',
  digest: 'hmac-sha256',
});

const buf = await fs.readFile('./big-video.mp4');
const result = await new MultipartClient(client).upload(
  fromBuffer(buf, 'big-video.mp4', 'video/mp4'),
  {
    isPublic: true,
    concurrency: 5,
    onProgress: (p) => console.log(`[${p.phase}] ${(p.bytesUploaded / p.totalBytes * 100).toFixed(1)}%`),
  }
);
console.log('fileId:', result.fileId);

中途取消(AbortController)

const ac = new AbortController();

const uploadPromise = mc.upload(fromBlob(file), {
  isPublic: false,
  session: jwt,
  signal: ac.signal,           // ← 透传 AbortSignal
  onProgress: (p) => console.log(p.phase, p.bytesUploaded),
});

// 3 秒后用户点了"取消"
setTimeout(() => ac.abort(), 3000);

try {
  await uploadPromise;
} catch (e) {
  if (e.name === 'AbortError') {
    console.log('用户主动取消,server 端 uploadId 已被自动清理');
  }
}

断点续传(浏览器刷新后恢复)

// 关键:把 uploadId 持久化到 localStorage / sessionStorage
// MultipartClient 内部会自动调 status 跳过已收到的 part,
// 业务代码只要把 uploadId 缓存起来传给下次 upload() 即可

// ★ v1.0.3:MultipClient.upload() 每次都 new uploadId,断点续传由 SDK 透明处理
// 进阶场景(自己控制 uploadId 生命周期)请用低阶 5 方法

所有选项

interface MultipartUploadOptions {
  isPublic?:        boolean;          // private 还是 public(默认 false)
  session?:         string;           // JWT,isPublic=false 必填
  partSize?:        number;           // 自定义 part 字节数(默认让 server 决定,5-50MB)
  concurrency?:     number;           // 并发 part 数(默认 3)
  maxRetries?:      number;           // 单 part 失败重试次数(默认 5)
  retryBaseMs?:     number;           // 重试基础退避 ms(默认 500,指数退避)
  signal?:          AbortSignal;      // 取消信号
  meta?:            Record<string, unknown>;  // 业务元信息,透传到 server
  onProgress?:      (p: MultipartProgress) => void;
  fileHash?:        string;           // 客户端预计算的整文件 SHA-256(可选)
  computePartHash?: boolean;          // 是否算 part 级别 hash(默认 false)
}

interface MultipartProgress {
  bytesUploaded: number;
  totalBytes:    number;
  partsDone:     number;
  partsTotal:    number;
  phase:         'hashing' | 'init' | 'uploading' | 'merging' | 'done';
}

interface MultipartUploadResult {
  fileId:     string;
  filename:   string;
  size:       number;
  contentType: string;
  url:        string;     // /api/v1/upload/files/{fileId}
  dedup:      boolean;    // 是否秒传命中
  resumed:    boolean;    // 是否断点续传完成
  finalHash:  string;     // 整文件 SHA-256
}

主动查询 / 终止(高阶方法)

const mc = new MultipartClient(client);

// 查状态(返回 { status, receivedParts, totalParts, ... })
const st = await mc.status('upload_abc123', jwt);
console.log(`已收到 ${st.receivedParts.length}/${st.totalParts} 片`);

// 主动终止(清理磁盘 + 标记 ABORTED)
await mc.abort('upload_abc123', jwt);

⚙️ 方式二:低阶 5 方法(自定义传输层时用)

// 1. init(可带 expectedHash 争秒传)
const init = await client.multipartInit({
  filename: 'big.mp4',
  contentType: 'video/mp4',
  totalSize: file.byteLength,
  isPublic: false,
  session: jwt,
  // expectedHash: precomputedSha256,  // 客户端已算好可以传
});

if (init.dedup) {
  console.log('秒传命中,fileId =', init.fileId);
  return init.fileId;
}

const { uploadId, partSize, totalParts } = init;

// 2. part × N(可并发,可重复幂等)
const CONCURRENCY = 3;
const uploadPart = async (partNumber: number) => {
  const start = (partNumber - 1) * partSize;
  const end = Math.min(start + partSize, file.byteLength);
  const chunk = file.slice(start, end);
  await client.multipartPart({
    uploadId,
    partNumber,
    data: new Uint8Array(await chunk.arrayBuffer()),
    // partHash: await sha256Hex(chunk),  // 可选
    session: jwt,
  });
};

// 简易并发池
const queue = Array.from({ length: totalParts }, (_, i) => i + 1);
await Promise.all(
  Array.from({ length: CONCURRENCY }, async () => {
    while (queue.length) {
      const n = queue.shift()!;
      await uploadPart(n);
    }
  })
);

// 3. complete(可带 finalHash 完整性校验)
const result = await client.multipartComplete({
  uploadId,
  finalHash: precomputedSha256,
  session: jwt,
});
console.log('完成,fileId =', result.fileId);

// 4. (可选)主动终止
await client.multipartAbort({ uploadId, session: jwt });

// 5. (可选)查状态(断点续传)
const status = await client.multipartStatus({ uploadId, session: jwt });
// status.receivedParts = [1, 2, 3, 5]  ← 第 4 片没收到

📡 对应服务端 5 端点

| HTTP 端点 | 协议 | SDK 方法 | 用途 | |-----------|------|---------|------| | POST /api/v1/upload/multipart/init | TOP | client.multipartInit | 创建 uploadId,可选秒传 | | POST /api/v1/upload/multipart/part | binary | client.multipartPart | 上传分片(可重复幂等) | | POST /api/v1/upload/multipart/complete | TOP | client.multipartComplete | 合并分片,生成 fileId | | POST /api/v1/upload/multipart/abort | TOP | client.multipartAbort | 主动终止(清理磁盘) | | POST /api/v1/upload/multipart/status | TOP | client.multipartStatus | 查已收到的 part(续传,body 传 uploadId) |

服务端实现见 backend/apps/upload-service/src/routes/multipart.ts(~700 行,含 5min 清理任务 + 24h TTL)。

⚠️ 错误码(MULTIPART_* 1520-1590)

| code | 名称 | 含义 | 可重试 | |------|------|------|--------| | 1520 | MULTIPART_NOT_FOUND | uploadId 不存在(可能已 TTL 过期) | ❌ | | 1521 | MULTIPART_ALREADY_COMPLETED | uploadId 已完成 | ❌ | | 1522 | MULTIPART_ALREADY_ABORTED | uploadId 已终止 | ❌ | | 1523 | MULTIPART_EXPIRED | uploadId 超过 24h TTL | ❌ | | 1524 | MULTIPART_STATUS_INVALID | 状态机非法转移(并发冲突) | ❌ | | 1530 | MULTIPART_TOTAL_TOO_LARGE | 文件总大小 > 5GB | ❌ | | 1531 | MULTIPART_PART_TOO_LARGE | 单片 > 50MB | ❌ | | 1532 | MULTIPART_PART_TOO_SMALL | 单片 < 5MB(最后一篇可豁免) | ❌ | | 1533 | MULTIPART_INVALID_PART_NUMBER | partNumber 越界 | ❌ | | 1534 | MULTIPART_DUPLICATE_PART | 同 partNumber 重复且 hash 不一致 | ❌ | | 1535 | MULTIPART_PART_HASH_MISMATCH | 客户端声明的 part hash 与实际不一致 | ❌ | | 1536 | MULTIPART_FINAL_HASH_MISMATCH | 整文件 hash 不一致 | ❌ | | 1537 | MULTIPART_INCOMPLETE_PARTS | 收齐前调 complete | ❌ | | 1538 | MULTIPART_AUTH_REQUIRED | private 上传未传 JWT | ❌ | | 1539 | MULTIPART_INVALID_ARG | 参数非法(partSize<=0 等) | ❌ | | 1590 | MULTIPART_CLIENT_ABORTED | 用户主动取消(SDK 内部抛) | ❌ | | 1591 | MULTIPART_CLIENT_HASH_MISMATCH | 客户端 hash 校验失败 | ❌ | | 1600 | SERVICE_UNAVAILABLE | 服务降级/不可用(如 Redis 宕机、nonce 存储不可达) | ❌(请稍后重试) |

MultipartClient 默认只对可重试错误(RATE_LIMITED / 网络错 / 5xx)做重试,上表全部 ❌ 错误会立即抛 SecureError 给业务。

🔄 与 SecureClient 主链路的关系

| 维度 | SecureClient.post() 主链路 | MultipartClient.upload() 分片链路 | |------|------------------------------|-----------------------------------| | 单文件上限 | ~200MB(bodyLimit) | 5GB | | 协议 | TOP / custom / binary(整 envelope) | init/status 用 TOP,part 用 binary | | 进度 | ❌(一次性请求) | ✅ 5 phase 实时进度 | | 断点续传 | ❌ | ✅ | | 秒传 | ❌ | ✅ 整文件 SHA-256 | | 取消 | ❌ | ✅ AbortController | | 重试 | ❌(业务层做) | ✅ 指数退避,智能判断 | | 鉴权 | session | session(私有)/ 匿名(公共) |

推荐选型:

  • 任意大小 + 任意数量文件(主推) → SmartClient(1 行,服务端智能分流)
  • 单文件 + 想要完全控制 partSize → MultipartClient
  • 完全自定义传输层 → 5 个低阶 client.multipart*
  • ~~SecureClient.post('/upload/private')~~ → v1.4.x 方案 A 已删除,请用 MultipartClient(单文件 ≤ 5GB)或 SmartClient
  • ~~client.requestBinary() / client.post('/upload/batch')~~ → v1.4.x 方案 A 已删除,请用 SmartClient

🔌 Binary 协议(底层,v1.0.4 引入)

背景:binary 协议是 SDK 内部使用的第三个协议,把加密后的原始字节直接放在 x-secure-raw-base64 header,body 留给纯业务数据(JSON),避免 BASE64 编码膨胀 33%。

⚠️ v1.4.x 方案 A 状态:SecureClient.requestBinary() 业务方法已删除(它内部硬编码拼 /upload/{private,public},服务端这 2 个端点已下线)。binary 协议本身仍保留,仅供 SDK 内部 MultipartEndpointsmultipart/part 端点使用。

🎯 适用场景

| 场景 | 推荐 | 原因 | |------|------|------| | 任意大小 + 任意数量文件上传(主推) | SmartClient | 1 行调用,服务端智能分流 ONE_SHOT/MULTIPART | | 单文件 + 想要完全控制 partSize | MultipartClient | 5-50MB partSize 可调,秒传/续传/进度/取消 | | 分片上传的 part 端点(SDK 内部) | binary 协议(走 client.multipartPart()) | 5GB 大文件,BASE64 编码不可接受 | | ~~单文件 1MB-200MB~~ | ~~requestBinary~~ | ⚠️ v1.4.x 已删除,改用 MultipartClient(哪怕 1KB) | | 简单 JSON 业务 | client.post()(TOP) | TOP 协议已经够用 |

📐 协议格式(SDK 内部)

请求示例(以 multipart/part 端点为例,SDK 内部自动拼装):

POST /api/v1/upload/multipart/part
Content-Type: application/json
X-Secure-Raw-Base64: <iv|cipherText|authTag, base64 编码>

{
  "app_key": "demo-app",
  "method": "/api/v1/upload/multipart/part",
  "http_method": "POST",
  "biz_content": "{}",
  "sign": "...",
  "iv": "...",
  "authTag": "...",
  "timestamp": 1715824800000,
  "nonce": "uuid"
}

服务端处理:onRequest hook 检测到 X-Secure-Raw-Base64 header 时:

  1. 用 appKey 派生 AES key
  2. 解密 header 里的密文 → 原始二进制 Uint8Array
  3. 把解密后的 Uint8Array 挂到 (req as any).secureRaw
  4. biz_content 解密后挂到 (req as any).secureRequest.biz
  5. 业务 handler 直接用 secureRaw 即可
// 后端 multipart/part 端点(SDK 内部已封装,业务侧无需手写)
app.post('/multipart/part', async (req, reply) => {
  const sr = (req as any).secureRequest;
  const raw: Uint8Array = (req as any).secureRaw;
  // raw 就是解密后的 part 字节
  await savePart(raw, sr.biz.uploadId, sr.biz.partNumber);
  return { uploadId: sr.biz.uploadId, partNumber: sr.biz.partNumber, ok: true };
});

⚠️ 注意事项(SDK 内部维护者用)

  • body 业务字段必须为空(biz_content: '{}'),所有业务信息(meta/uploadId/partNumber)通过 query 或 header 传
  • 浏览器上传 File 对象前必须 new Uint8Array(await file.arrayBuffer())
  • 不要在 body 放业务 JSON,否则会与 raw 冲突
  • binary 协议只在 MultipartEndpoints.sendBinary 内部使用,业务代码不要自己构造 envelope
  • v1.4.x 之前 SecureClient.requestBinary() 业务方法已删除,新代码不要调用

⚠️ v1.4.x 已删除的接口(迁移必看)

背景:/api/v1/upload/private /public /batch 三个老端点 + SecureClient.requestBinary() 业务方法在 v1.4.x 方案 A 清理中已彻底删除(不是 Deprecation),请求会直接 404。 替代品:SmartClient.upload() 一站式搞定任意大小 + 任意数量文件上传。 服务端同时删除:backend/apps/upload-service/src/routes/upload.ts(487 行,3 个端点)已删除,SmartClient 内部走 /smart/init + /multipart/*

❌ 删除清单(不要再用)

| 已删除 | 替代 | 说明 | |------|------|------| | SecureClient.requestBinary({ method, data, filename, contentType, ... }) | new MultipartClient(client).upload(fromBlob(file), { isPublic, session }) | 单文件场景(1KB~5GB) | | client.post('/api/v1/upload/private', { data, filename }) | 同上,加 session: jwt | 单文件私有上传 | | client.post('/api/v1/upload/public', { data, filename }) | 同上,isPublic: true | 单文件公共上传 | | client.post('/api/v1/upload/batch', { files: [...] }) | new SmartClient(client).upload(files.map(smartFromBlob), { isPublic, session }) | 批量场景,主替代 |

✅ 迁移示例

// ❌ 旧:requestBinary(v1.4.x 已删除)
const r = await client.requestBinary({
  method: 'file.upload',
  data: fileBuf,
  filename: 'photo.png',
  contentType: 'image/png',
  session: jwt,
});

// ✅ 新:MultipartClient(单文件)
const r = await new MultipartClient(client).upload(fromBlob(file), {
  isPublic: false,
  session: jwt,
  onProgress: (p) => console.log(`[${p.phase}] ${p.percent}%`),
});

// ❌ 旧:post('/upload/batch')(v1.4.x 已删除)
const r = await client.post('/api/v1/upload/batch', {
  files: [{ filename: 'a.png', contentType: 'image/png', data: base64str }, ...],
  isPublic: false,
});

// ✅ 新:SmartClient(批量,任意大小)
const r = await new SmartClient(client).upload(
  files.map((f) => smartFromBlob(f)),
  { isPublic: false, session: jwt, onProgress: (p) => { /* ... */ } }
);

🧠 智能上传(Smart Upload)v1.4.0 新增

统一入口:POST /api/v1/upload/smart/init(TOP 协议),后续 part/complete/abort/status 复用 multipart/* 服务端智能分流:1KB 走 ONE_SHOT,5GB 走 MULTIPART(动态 partSize) 一站式封装:new SmartClient(client).upload(files, { isPublic, session }) —— N 个文件 + 任意大小,1 行调用

🎯 适用场景

| 场景 | 推荐 API | 原因 | |------|---------|------| | 任意数量 + 任意大小文件混合上传(主推) | SmartClient.upload() | 1 个 init 搞定批量,服务端按文件大小选策略,SDK 自动处理 ONE_SHOT/MULTIPART/秒传 | | 单文件 ≤ 1MB(纯 ONE_SHOT) | SmartClientMultipartClient | Smart 默认走 ONE_SHOT,2 RTT 完成 | | 单文件 > 200MB 但只有 1 个 | MultipartClient | 直接分片,不需要批量的 init 配额 | | 想要完全自定义传输层 | client.multipartInit/Part/Complete | 5 个低阶方法裸用 |

推荐:所有新业务直接用 SmartClient(1 行,任意大小/任意数量,服务端智能分流)。MultipartClient 适合"单文件 + 想完全控制 partSize"的场景。requestBinary() 业务方法与 /upload/{private,public,batch} 三个老端点已在 v1.4.x 方案 A 中彻底删除,直接请求会 404。

🧠 服务端智能分流策略

SmartClient 把"上传什么文件"完全交给 服务端决定:

| 文件大小 | 策略 | partSize | totalParts | 服务端 RTT | 适用 | |---------|------|----------|-----------|-----------|------| | 0 字节 | ONE_SHOT | 0 | 1 | 2 (part+complete) | 空文件 | | ≤ 1MB | ONE_SHOT | = 文件大小 | 1 | 2 | 小文件,直传最快 | | 1MB ~ 1GB | MULTIPART | 1MB | 1 ~ 1024 | N+1 | 中等文件 | | 1GB ~ 10GB | MULTIPART | 5MB | 200 ~ 2048 | N+1 | 大文件(动态放大减少 part 数) | | > 10GB | MULTIPART | 10MB | > 1024 | N+1 | 超大文件 |

为什么动态放大 partSize?

  • 5GB 用 1MB/part → 5120 part,DB/Redis/客户端并发调度压力大
  • 5GB 用 5MB/part → 1024 part,开销可接受,网络利用率更高

🚀 快速开始(浏览器 + <input type="file" multiple>)

<input type="file" id="picker" multiple />
<progress id="bar" max="100" value="0"></progress>
<pre id="out"></pre>
<script type="module">
  import { SecureClient, SmartClient, smartFromBlob } from 'https://cdn.jsdelivr.net/npm/[email protected]/dist/client.mjs';

  const client = new SecureClient({
    baseURL: 'https://api.example.com',
    appKey: 'demo-app',
    appSecret: 'demo-app-secret-1234567890abcdef',
    encryptionKey: 'a'.repeat(64),
    protocol: 'top',
    cipher: 'aes-256-gcm',
    digest: 'hmac-sha256',
  });

  const smart = new SmartClient(client);

  document.getElementById('picker').addEventListener('change', async (e) => {
    const files = Array.from(e.target.files);
    const bar = document.getElementById('bar');
    const out = document.getElementById('out');

    try {
      const results = await smart.upload(
        files.map((f) => smartFromBlob(f)),
        {
          isPublic: false,                              // 私有批量
          session: localStorage.getItem('jwt'),          // ★ 必传,否则服务端 401 BINARY_AUTH_REQUIRED
          onProgress: (p) => {
            const pct = (p.totalBytesUploaded / p.totalBytes) * 100;
            bar.value = pct;
            out.textContent =
              `[${p.filePhase}] file ${p.fileIndex + 1}/${p.totalFiles} ` +
              `(${p.fileBytesUploaded}/${p.fileTotalBytes}B) ` +
              `aggregate ${pct.toFixed(1)}%`;
          },
        }
      );

      out.textContent = `✅ ${results.filter((r) => r.ok).length}/${results.length} 成功\n` +
        results.map((r) => r.ok
          ? `  ${r.dedup ? '⚡' : '📤'} ${r.filename} → ${r.url}${r.dedup ? ' (秒传)' : ''}`
          : `  ❌ ${r.filename}: ${r.message}`).join('\n');
    } catch (e) {
      out.textContent = `❌ ${e.message}`;
    }
  });
</script>

📋 完整 API

import { SmartClient, smartFromBlob, smartFromBuffer } from 'secure-crypto-top-zod-sdk/client';

// 方式 1:默认 50(向后兼容,v1.0.7 及之前的所有调用方都不用改)
const smart = new SmartClient(secureClient);

// 方式 2:v1.0.8+ 构造时调高默认(整个 SmartClient 实例都生效)
const smart = new SmartClient(secureClient, { maxFilesPerBatch: 200 });

// 方式 3:v1.0.8+ per-call 覆盖(更灵活,不影响其他调用)
const smart = new SmartClient(secureClient);
const results = await smart.upload(largeBatch, { maxFilesPerBatch: 100 });

// 一站式上传:N 个 SmartInput + 选项 → N 个 SmartUploadResult
const results = await smart.upload(inputs, {
  // 基础
  isPublic: false,                      // 默认 false(私有)
  session: jwt,                          // ★ isPublic=false 必填;批量内混用 isPublic 时由 file.isPublic 决定
  meta: { source: 'profile-photos' },    // 业务元信息,透传到 File.meta

  // 重试
  maxRetries: 3,                         // 单文件失败重试次数(默认 3)
  retryBaseMs: 500,                      // 指数退避基数(默认 500ms)

  // 批量上限(v1.0.8 新增):覆盖构造时的配置,默认 50
  //  · 实际通过量取 client / server 两边较小值
  //  · 设为 1000+ 需确认 server 端 secure-upload-fastify-sdk 的 smartMaxFiles 也调高
  maxFilesPerBatch: 200,

  // 取消
  signal: ac.signal,                     // AbortSignal,整批粒度取消

  // 进度(聚合:perFile + perPart + aggregate)
  onProgress: (p) => { /* p 见下方 */ },

  // 优化:客户端预计算的整文件 hash(避免 SDK 自己扫)
  fileHashes: {
    'photo1.jpg': 'a1b2c3...64位hex',
  },
});

// 结果是数组:每个文件一项
results.forEach((r) => {
  if (r.ok) {
    console.log(`${r.filename} → fileId=${r.fileId}, url=${r.url}, dedup=${r.dedup}, strategy=${r.strategy}`);
  } else {
    console.error(`${r.filename} 失败: code=${r.code}, message=${r.message}`);
  }
});

🆕 v1.0.8+ 批量上限 maxFilesPerBatch 配置(完整说明)

| 维度 | 规则 | |------|------| | SDK 默认值 | 50(SmartClient.DEFAULT_MAX_FILES_PER_BATCH) | | 三层覆盖优先级(per-call > 构造器 > SDK 默认) | 1) smart.upload(files, { maxFilesPerBatch: 200 })2) new SmartClient(client, { maxFilesPerBatch: 200 })3) SmartClient.DEFAULT_MAX_FILES_PER_BATCH = 50 | | server 端限制 | 由 secure-upload-fastify-sdksmartMaxFiles 配置控制,取 client / server 两边较小值 | | 推荐值 | 浏览器相册批量 100~500 · Node 后台批量 200~1000 · ≥ 1000 需谨慎(init 配额压力大) | | 不传 / 设为 0 / 负数 / 小数 | 抛 INVALID_REQUEST(防御非法配置) | | 不传 vs 传 0 | 不传走默认 50;传 0 / 负数 / 小数 → 立即抛错,不会"静默用 50" | | 典型场景 | 74 个文件想一把传(原 50 限制)→ new SmartClient(client, { maxFilesPerBatch: 200 })某些调用临时 100 个,其他保持 50 → smart.upload(files, { maxFilesPerBatch: 100 })服务端后台导入 500 个文件 → new SmartClient(client, { maxFilesPerBatch: 600 }) + server smartMaxFiles: 600 |

📐 SmartInput 适配器

// 浏览器:File / Blob
const input = smartFromBlob(file, 'photo.png');  // filename 可选,默认用 file.name

// Node:Buffer / Uint8Array
const buf = await fs.readFile('./big.mp4');
const input = smartFromBuffer(buf, 'big.mp4', 'video/mp4');

// 自定义:实现 read(offset, length) → Uint8Array
const myInput: SmartInput = {
  filename: 'data.bin',
  contentType: 'application/octet-stream',
  size: 1024 * 1024,
  async read(offset, length) {
    return new Uint8Array(length); // 你的 IO
  },
};

📊 进度回调 SmartProgress

interface SmartProgress {
  fileIndex: number;              // 当前正在处理的文件 index(0-based)
  fileBytesUploaded: number;      // 当前文件已上传字节
  fileTotalBytes: number;         // 当前文件总大小
  filePartsDone: number;          // 当前文件 part 进度
  filePartsTotal: number;
  filePhase: 'hashing' | 'init' | 'uploading' | 'merging' | 'done' | 'failed';
  totalBytesUploaded: number;     // 整批聚合
  totalBytes: number;
  totalFiles: number;
  filesDone: number;
}

📤 SmartUploadResult 单文件结果

interface SmartUploadResult {
  index: number;
  ok: boolean;
  filename: string;
  size: number;

  // 成功字段
  dedup?: boolean;                 // 是否秒传命中
  fileId?: string;
  url?: string;                    // /api/v1/upload/files/{fileId}
  contentType?: string;
  finalHash?: string;
  strategy?: 'ONE_SHOT' | 'MULTIPART';
  totalParts?: number;
  partSize?: number;
  uploadId?: string;

  // 失败字段
  error?: string;
  code?: number;
  message?: string;
}

🛰️ 服务端对应端点

| HTTP 端点 | 协议 | SDK 方法 | 用途 | |-----------|------|---------|------| | POST /api/v1/upload/smart/init | TOP | client.smartInit() | 唯一新增。批量 init,服务端智能分流 | | POST /api/v1/upload/multipart/part | binary | client.multipartPart() | 上传分片(复用,所有 part 都走这里) | | POST /api/v1/upload/multipart/complete | TOP | client.multipartComplete() | 合并(复用) | | POST /api/v1/upload/multipart/abort | TOP | client.multipartAbort() | 终止清理(复用) | | POST /api/v1/upload/multipart/status | TOP | client.multipartStatus() | 续传查询(复用) |

设计原则:smart/init 只做"批量 + 智能策略",part/complete/abort/status 4 个端点完全复用 multipart/*(协议、限流、状态机、合并逻辑全相同)。SDK 内部 SmartClient 也是:init 调 client.smartInit,后续 part/complete/abort/status 调 client.multipart*

⚠️ 鉴权 & 限流

| 维度 | 规则 | |------|------| | 私有批量 | isPublic: false 时必须传 session: jwt,服务端从 x-secure-session header 拿 userId(挂到 file.userId) | | 公共批量 | isPublic: true 可不传 session,userId=null,任意人可读 | | 混合批量(部分公开 + 部分私有) | 仍传 session,服务端按每个文件自己的 isPublic 决定是否校验 session | | 批量上限 | 1 次 init 默认最多 50 个文件(v1.0.8+ 可通过 SmartClientConfig.maxFilesPerBatchupload({ maxFilesPerBatch }) 调高;server 端 smartMaxFiles 独立配置,取较小值) | | 限流 | 60s 滑动窗口,默认 30 次配额,按 files.length 消耗(批量 30 个 = 30 次配额);配额与 /multipart/init 共享 key 前缀,避免双入口绕过 | | JWT 校验 | 服务端 base64-decode payload,exp 校验,sub 作为 userId | | 文件大小 | size > UPLOAD_MULTIPART_FILE_MAX_SIZE(默认 5GB)→ 单文件标记 MULTIPART_TOTAL_TOO_LARGE 失败,不阻塞其他文件 |

⚠️ 错误码

| code | 名称 | 含义 | 可重试 | |------|------|------|--------| | 1503 | BINARY_AUTH_REQUIRED | 私有上传未传 JWT(session 缺失) | ❌ | | 1530 | MULTIPART_TOTAL_TOO_LARGE | 文件 > 5GB | ❌ | | 1429 | RATE_LIMITED | 60s 配额用尽(批量场景:第 N+1 个文件起) | ✅(等 retryAfterMs) | | 1400 | NOT_FOUND | 私有秒传命中但 userId 不匹配 | ❌ | | — | INVALID_REQUEST | files 为空 / 超过 maxFilesPerBatch(v1.0.8+ 默认 50,可配) / maxFilesPerBatch 非正整数 / size 非法 | ❌ |

单文件失败(ok: false)不阻塞其他文件,整体 HTTP 仍 200;业务代码只需遍历 results 数组按 r.ok 分支。

🛠️ 低阶:client.smartInit()(自定义传输层时用)

// 只想用 smart 的"批量 init"能力,后续 part/complete 自己写?用这个
const initResp = await client.smartInit({
  files: [
    { filename: 'a.png', size: 1024,        isPublic: true,  expectedHash: 'abc...' },
    { filename: 'b.zip', size: 5 * 1024**3, isPublic: false, session: jwt },  // session 在 file 级别也支持
  ],
  session: jwt,                                // ★ 顶层 session(私有批量必填,走 x-secure-session header)
});

for (const r of initResp.files) {
  if (!r.ok) console.error('init 失败', r.index, r.message);
  else if (r.dedup) console.log('秒传', r.fileId);
  else console.log(`分片 ${r.totalParts} 个,partSize=${r.partSize},strategy=${r.strategy}`);
}

🔄 与 MultipartClient 的关系

| 维度 | MultipartClient.upload() | SmartClient.upload() | |------|----------------------------|------------------------| | 输入 | 1 个 MultipartInput | N 个 SmartInput(批量) | | init 端点 | /multipart/init | /smart/init(批量 + 智能策略) | | 后续 part/complete | 复用 multipart/* | 复用 multipart/* | | 适用大小 | 由客户端决定 partSize(5-50MB) | 由服务端决定(1MB/5MB/10MB 动态) | | 一次性 1MB 文件 | 1 part(浪费并发) | ONE_SHOT(2 RTT 完成) | | 5GB 文件 | 1MB/part → 5120 part(压力大) | 5MB/part → 1024 part(更优) | | 批量支持 | ❌(需循环) | ✅(1 个 init 完成 N 个文件) | | 鉴权 | 顶层 session | 顶层 session(混合时按 file.isPublic) | | 限流 key | rl:mp:init:{userId} | rl:mp:init:{userId}(共用配额) | | 推荐场景 | 单文件 + 完全控制 partSize | 任意数量 + 任意大小(主推) |

💡 选型速记:

  • 批量 / 任意大小SmartClient
  • 单文件 + 想要完全控制 partSizeMultipartClient
  • 完全自定义传输层 → 5 个低阶 client.multipart*

🛰️ SSR / Edge 场景(Nuxt 3 · Next.js · Cloudflare Workers)

SSR 框架的优势在于 appSecret / encryptionKey 永远不会进前端 bundle —— 浏览器只调 BFF,BFF 持有 secret。下面是 3 个最常见平台的最小可用示例。

🟢 Nuxt 3(server/api/*.ts + Nitro)

适合:Nuxt 3 + Vue 3 SSR 项目。BFF 用 Nitro 的 defineEventHandler 手动跑加密/解密(不走 Fastify 插件)。

1. 安装

pnpm add secure-crypto-top-zod-sdk

2. server/utils/secure.ts —— 服务端工具函数

// server/utils/secure.ts
import { getProtocol, pickProtocol, SecureError, ErrorCode } from 'secure-crypto-top-zod-sdk';
import { setBackend } from 'secure-crypto-top-zod-sdk/runtime';
import { NodeCryptoBackend, NODE_CAPABILITIES } from 'secure-crypto-top-zod-sdk/runtime';

// 显式注入 Node backend(SSR 容器里 isNode 探测可能不准)
setBackend(new NodeCryptoBackend(), NODE_CAPABILITIES);

// 多 app 配置(实际项目应该从环境变量 / Secret Manager 读取)
export const APPS = {
  'demo-app': {
    appSecret: process.env.APP_SECRET!,
    encryptionKey: Buffer.from(process.env.APP_ENCRYPTION_KEY_HEX!, 'hex'), // 32 字节
  },
} as const;

export function getApp(appKey: string) {
  const app = APPS[appKey as keyof typeof APPS];
  if (!app) throw new SecureError(ErrorCode.APP_NOT_FOUND, `[BFF] 未注册 app: ${appKey}`);
  return app;
}

/** 解密请求 envelope */
export async function decryptRequest(rawBody: any) {
  const protocol = pickProtocol(rawBody);
  const ctx = getProtocol(protocol.kind);
  // ★ 真正负责验签+解密的逻辑(下面 Next.js 章节有对应 Node 写法)
  return ctx.parseAndVerify(rawBody, {
    appKey: rawBody.app_key ?? rawBody.appKey,
    appSecret: getApp(rawBody.app_key ?? rawBody.appKey).appSecret,
    encryptionKey: getApp(rawBody.app_key ?? rawBody.appKey).encryptionKey,
  });
}

/** 加密响应 envelope */
export async function encryptResponse(
  appKey: string,
  plainBiz: unknown,
  method: string,
  path: string,
) {
  const app = getApp(appKey);
  const protocol = pickProtocol({ protocol: 'top' });
  return protocol.buildResponse(
    { appKey, method, path, biz: plainBiz, body: plainBiz },
    { appSecret: app.appSecret, encryptionKey: app.encryptionKey, hmacSecret: app.appSecret }
  );
}

3. server/api/secure/[...path].ts —— 通用加密代理

// server/api/secure/[...path].ts
import { defineEventHandler, readBody, getRequestURL, setResponseHeader } from 'h3';
import { decryptRequest, encryptResponse } from '~/server/utils/secure';
import { SecureError } from 'secure-crypto-top-zod-sdk';

export default defineEventHandler(async (event) => {
  const url = getRequestURL(event);
  const body = await readBody(event);
  const appKey = (body?.app_key ?? body?.appKey ?? 'demo-app') as string;

  try {
    const sr = await decryptRequest(body);

    // ★ 在这里把解密后的业务数据交给真实业务
    // 例如:调内部 service / 查 DB / 转发到后端
    const biz = sr.biz as Record<string, unknown>;
    const data = await handleBusiness(sr.method, sr.path, biz);

    const envelope = await encryptResponse(appKey, { success: true, data }, sr.method, sr.path);
    setResponseHeader(event, 'Content-Type', 'application/json');
    return envelope;
  } catch (e) {
    if (e instanceof SecureError) {
      // ★ 业务错误:返回明文,前端 SecureClient 会识别
      return { success: false, code: e.code, message: e.message };
    }
    throw e;
  }
});

async function handleBusiness(method: string, path: string, biz: any) {
  // TODO: 接你自己的业务
  if (path === '/api/login') {
    return { user: { id: 1, name: biz.username }, token: 'mock-token' };
  }
  return { echo: biz };
}

4. 客户端(composables/useApi.ts)

// composables/useApi.ts
import { secureClient } from '@/lib/secure-client'; // 上一节 Vue 3 那个单例
export const useApi = () => secureClient;

流量:浏览器 → /api/secure/login(POST) → Nitro 解密 → 业务 → 加密响应 → 浏览器解密 整个过程 appSecret 一直在服务端容器里,前端 bundle 看不到。


🔵 Next.js(App Router + Route Handler)

适合:Next.js 14+ App Router / Edge Runtime。两种 Runtime 都给示例。

A. Node Runtime(完整能力,推荐)

// app/api/secure/[...path]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { pickProtocol, getProtocol, SecureError } from 'secure-crypto-top-zod-sdk';

export const runtime = 'nodejs';          // 显式 Node,SM4/SM3 才能用
// export const runtime = 'edge';        // 改 edge 时记得用 WebCryptoBackend

const APPS: Record<string, { appSecret: string; encryptionKey: Buffer }> = {
  'demo-app': {
    appSecret: process.env.APP_SECRET!,
    encryptionKey: Buffer.from(process.env.APP_ENCRYPTION_KEY_HEX!, 'hex'),
  },
};

export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
  const { path } = await ctx.params;
  const body = await req.json();
  const appKey = body?.app_key ?? body?.appKey ?? 'demo-app';
  const app = APPS[appKey];
  if (!app) return NextResponse.json({ success: false, code: 1004, message: 'app not found' });

  try {
    const protocol = pickProtocol(body);
    const protoImpl = getProtocol(protocol.kind);
    const sr = await protoImpl.parseAndVerify(body, {
      appKey,
      appSecret: app.appSecret,
      encryptionKey: app.encryptionKey,
    });

    // 业务处理
    const data = await handleBusiness(sr.method, '/' + path.join('/'), sr.biz);

    const envelope = await protoImpl.buildResponse(
      { appKey, method: sr.method, path: sr.path, biz: { success: true, data }, body: data },
      { appSecret: app.appSecret, encryptionKey: app.encryptionKey, hmacSecret: app.appSecret }
    );
    return NextResponse.json(envelope);
  } catch (e) {
    if (e instanceof SecureError) {
      return NextResponse.json({ success: false, code: e.code, message: e.message });
    }
    return NextResponse.json({ success: false, code: 5000, message: String(e) }, { status: 500 });
  }
}

async function handleBusiness(method: string, path: string, biz: any) {
  if (path === '/api/login') return { user: { id: 1, name: biz.username }, token: 'mock' };
  return { echo: biz };
}

B. Edge Runtime(用 Web Crypto,体积更小冷启动更快)

// app/api/secure-edge/[...path]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { pickProtocol, getProtocol, SecureError } from 'secure-crypto-top-zod-sdk';
import { setBackend } from 'secure-crypto-top-zod-sdk/runtime';
import { WebCryptoBackend, WEB_CAPABILITIES } from 'secure-crypto-top-zod-sdk/runtime';

export const runtime = 'edge';
setBackend(new WebCryptoBackend(), WEB_CAPABILITIES);  // ★ Edge 必须显式注入

// encryptionKey 在 Edge 里是 Uint8Array 而不是 Buffer
const APPS: Record<string, { appSecret: string; encryptionKey: Uint8Array }> = {
  'demo-app': {
    appSecret: process.env.APP_SECRET!,
    encryptionKey: new Uint8Array(Buffer.from(process.env.APP_ENCRYPTION_KEY_HEX!, 'hex')),
  },
};

// 其余逻辑和 Node 版一样,只是 encryptionKey 类型从 Buffer 换成 Uint8Array
// ...

⚠️ Edge Runtime 没有 node:crypto,所以 SM4 / SM3 / MD5 / RSA-OAEP 不可用。AES-256-GCM / HMAC-SHA256 走 Web Crypto OK。

C. 中间件做统一解密(可选)

如果想所有 /api/* 都自动解密,可以在 middleware.ts 里拦截:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { pickProtocol, getProtocol } from 'secure-crypto-top-zod-sdk';

export async function middleware(req: NextRequest) {
  if (!req.nextUrl.pathname.startsWith('/api/')) return NextResponse.next();

  // 中间件里只能读 body 一次,后面 route.ts 还要用
  // 实际工程建议:把 envelope 解析放在 route.ts,中间件只做粗粒度过滤(签名头、时间戳窗口)
  // 这里只演示思路:
  const ct = req.headers.get('content-type') ?? '';
  if (ct.includes('application/json')) {
    const cloned = req.clone();
    const body = await cloned.json().catch(() => null);
    if (body?.app_key || body?.encryptedPayload) {
      // 标记一下,route.ts 看到此 header 就不再走 Fastify 插件
      const headers = new Headers(req.headers);
      headers.set('x-secure-envelope', '1');
      return NextResponse.next({ request: { headers } });
    }
  }
  return NextResponse.next();
}

export const config = { matcher: ['/api/:path*'] };

🟠 Cloudflare Workers / Vercel Edge Functions

Workers 没有 Fastify,直接用 fetch handler 写。BFF 模式最纯粹的形态。

// src/index.ts (Cloudflare Workers)
import { pickProtocol, getProtocol, SecureError } from 'secure-crypto-top-zod-sdk';
import { setBackend } from 'secure-crypto-top-zod-sdk/runtime';
import { WebCryptoBackend, WEB_CAPABILITIES } from 'secure-crypto-top-zod-sdk/runtime';

setBackend(new WebCryptoBackend(), WEB_CAPABILITIES);  // ★ Workers 必加

export interface Env {
  APP_SECRET: string;
  APP_ENCRYPTION_KEY_HEX: string;  // 64 字符 hex
}

export default {
  async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    if (req.method !== 'POST') {
      return new Response('method not allowed', { status: 405 });
    }

    const body = await req.json().catch(() => null);
    if (!body) return new Response('invalid json', { status: 400 });

    const appKey = body.app_key ?? body.appKey ?? 'demo-app';
    const encryptionKey = new Uint8Array(
      Buffer.from(env.APP_ENCRYPTION_KEY_HEX, 'hex')
    );

    try {
      const protocol = pickProtocol(body);
      const protoImpl = getProtocol(protocol.kind);
      const sr = await protoImpl.parseAndVerify(body, {
        appKey,
        appSecret: env.APP_SECRET,
        encryptionKey,
      });

      // 业务:转发到内部 origin / KV / D1
      const upstreamUrl = new URL(req.url);
      const upstream = await fetch(
        new URL(sr.path, 'https://your-internal-service.example.com'),
        {
          method: sr.method as any,
          headers: { 'content-type': 'application/json', 'x-forwarded-from-bff': '1' },
          body: sr.biz ? JSON.stringify(sr.biz) : undefined,
        }
      );
      const data = await upstream.json();

      const envelope = await protoImpl.buildResponse(
        { appKey, method: sr.method, path: sr.path, biz: { success: true, data }, body: data },
        { appSecret: env.APP_SECRET, encryptionKey, hmacSecret: env.APP_SECRET }
      );
      return new Response(JSON.stringify(envelope), {
        headers: { 'content-type': 'application/json' },
      });
    } catch (e) {
      const code = e instanceof SecureError ? e.code : 5000;
      const message = e instanceof SecureError ? e.message : String(e);
      return new Response(JSON.stringify({ success: false, code, message }), {
        status: 200,  // 业务错误用 200 + 业务 success:false
        headers: { 'content-type': 'application/json' },
      });
    }
  },
};
# wrangler.toml
name = "my-secure-bff"
main = "src/index.ts"
compatibility_date = "2024-09-01"

[vars]
APP_SECRET = "demo-app-secret-1234567890abcdef"
APP_ENCRYPTION_KEY_HEX = "aaaa...64个a"

Worker 端用 env.APP_SECRET 而不是 import.meta.env,这是 Cloudflare 的标准 secrets 管理。


📊 三种 SSR 平台对比

| 维度 | Nuxt 3 (Nitro) | Next.js (Node) | Next.js (Edge) | Cloudflare Workers | |---|---|---|---|---| | 冷启动 | 中 | 慢 | | 最快 | | 可用算法 | 全部 | 全部 | AES-GCM/HMAC only | AES-GCM/HMAC only | | appSecret 暴露面 | 服务端 | 服务端 | 服务端 | 服务端(env) | | 与 Fastify 插件复用 | ❌ 自实现 | ❌ 自实现 | ❌ 自实现 | ❌ 自实现 | | 推荐场景 | Vue SSR 全栈 | Next 全栈 | 边缘 SSR | 全球边缘 BFF |

⚠️ SSR 平台不直接用 secureCryptoPlugin 插件(它是 Fastify 专用),而是手写 ~30 行的 parseAndVerify + buildResponse,逻辑和插件完全等价。


🌐 跨运行时能力

浏览器(零 polyfill)

SecureClient 在浏览器完全无需 polyfill:

| 特性 | 实现 | |------|------| | AES-256-GCM | crypto.subtle.encrypt/decrypt | | HMAC-SHA256/512 | crypto.subtle.sign/verify | | SHA-256 摘要 | crypto.subtle.digest | | 随机数 | crypto.getRandomValues | | UUID v4 | crypto.randomUUID | | 时序安全比较 | 自实现(常数时间) |

<!-- 浏览器使用,直接 ESM import,不需要打包 -->
<script type="importmap">
  {
    "imports": {
      "secure-crypto-top-zod-sdk/client": "https://cdn.jsdelivr.net/npm/[email protected]/dist/client.mjs"
    }
  }
</script>
<script type="module">
  import { SecureClient } from 'secure-crypto-top-zod-sdk/client';
  const client = new SecureClient({
    baseURL: 'https://api.example.com',
    appKey: 'demo-app',
    encryptionKey: 'a'.repeat(64),                 // 64 字符 hex(32 字节)
    appSecret: 'demo-app-secret-1234567890abcdef',  // 签名密钥(>= 32 字符)
    protocol: 'top',
    cipher: 'aes-256-gcm',
    digest: 'hmac-sha256',
  });
  const r = await client.post('/api/v1/secure-gateway', { foo: 'bar' });
  console.log(r);
</script>

Node.js(完整能力)

支持所有算法,包括 SM4 / SM3 / MD5 / RSA-OAEP / AES-CBC。

import { getCipher, getDigest, isNode, isBrowser, listSupportedAlgorithms } from 'secure-crypto-top-zod-sdk';

// 显式探测运行时
console.log(isNode);  // true
console.log(isBrowser);  // false

// 算法注册表自动按能力过滤
const ciphers = listSupportedAlgorithms();
console.log(ciphers);
// { ciphers: ['aes-256-gcm', 'aes-256-cbc', 'aes-128-cbc', 'sm4-cbc', 'rsa-oaep-aes-256-gcm'], ... }

跨运行时抽象

import { getBackend, setBackend, CryptoBackend } from 'secure-crypto-top-zod-sdk/runtime';

// 默认自动检测
const b = getBackend();
const uuid = b.randomUUID();
const bytes = b.randomBytes(16);

// 自定义 backend(测试场景)
setBackend(myCustomBackend, myCapabilities);

📐 架构

┌─────────────────────────────────────────────────────┐
│                  应用层 (User code)                │
│   Vue 3 / React 18 / Vanilla JS / Node.js         │
└─────────────────────────────────────────────────────┘
                    ↓
┌──────────────┬──────────────┬─────────────────────┐
│ SecureClient │ Fastify      │ Standalone (Node)   │
│ (Browser)    │ Plugin       │ (build/parse/verify)│
└──────────────┴──────────────┴─────────────────────┘
                    ↓
┌─────────────────────────────────────────────────────┐
│            Protocol Strategies                      │
│  customProtocol  |  topProtocol  |  binaryProtocol │
└─────────────────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────────────────┐
│         Algorithms (Cipher | Digest)                │
│  AES-GCM  AES-CBC  SM4  RSA-OAEP │ HMAC  SM3  MD5  │
└─────────────────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────────────────┐
│    CryptoBackend (跨运行时抽象)                      │
│  NodeCryptoBackend  |  WebCryptoBackend             │
│   (node:crypto)       (crypto.subtle)               │
└─────────────────────────────────────────────────────┘

🔐 安全特性

启动校验(吸收老 SDK)

new KeyStore().set('app1', { encryptionKey: Buffer.alloc(8), hmacSecret: 'short' });
// 立即抛错:app app1 的 encryptionKey 至少 16 字节(当前 8)

new SecureClient({ encryptionKey: 'short', ... });
// 立即抛错:[SecureClient] encryptionKey 必须是 64 字符 hex (32 字节)

zod 字段校验(吸收老 SDK)

import { CustomRequestSchema, TopRequestSchema } from 'secure-crypto-top-zod-sdk';

const r = CustomRequestSchema.safeParse(rawBody);
if (!r.success) {
  // 详细错误:encryptedPayload: encryptedPayload 不能为空;iv: iv 不能为空; ...
}

响应验签(吸收老 SDK)

const client = new SecureClient({
  // ...
  verifyResponse: true,              // 启用响应签名校验
  responseAppSecret: '...',          // 可选,默认复用 appSecret(兼容旧 responseHmacSecret)
});

防重放 / 时序攻击

  • Nonce 默认 30s 窗口(可配 REQUEST_WINDOW_MS,生产建议 ≤ 60s) + Redis NX SET 拦截
  • timingSafeEqual 常数时间比较(防时序攻击)
  • fail closed:Redis 不可用 → 直接抛 SERVICE_UNAVAILABLE(1600) + 503 拒服务,不再静默放行

🛡️ 信封走私防护(v1.0.4 新增)

攻击场景:攻击者抓到合法 envelope,把 method/http_method 改成其他接口,试图越权访问或绕过权限。SDK 用 4 道闸防这种攻击:

| 层 | 机制 | 配置项 | |----|------|--------| | 协议层 | envelope.method + envelope.http_method 进签名源 | 改了就 SIGNATURE_MISMATCH | | gateway 层 | 强校验 envelope.method === req.url.path(剥 query) | ENFORCE_ENVELOPE_METHOD_MATCH / ENFORCE_ENVELOPE_PATH_MATCH | | IP 层 | Redis 滑动窗口计数,60s 失败超阈值 → 自动封禁 5min | IP_BAN_THRESHOLD / IP_BAN_TTL_SEC | | fail closed | Redis 不可用 → 直接 503 拒服务,攻击窗口被切断 | SERVICE_UNAVAILABLE(1600) |

// 危险场景举例(被 SDK 完全阻断):
// 1. 攻击者抓到 POST /api/user/login 的合法 envelope
// 2. 改成 GET /api/user/list 转发,试图越权读用户列表
// 3. 结果:
//    - envelope.method 改了 → 签名 mismatch,直接拒
//    - envelope.http_method 改了 → 签名 mismatch,直接拒
//    - 1 分钟内连续失败 → IP 封禁 5min
//    - Redis 挂了想绕过? → 整个服务 fail closed,所有请求 503

📦 体积对比(v1.0.8,含分片 + 批量 + binary + 信封走私防护 + 智能上传 + 可配批量上限)

| 场景 | bundle 大小 | 依赖 | |------|-------------|------| | 浏览器 ESM (index.browser.mjs) | ~247 KB | 0 外部依赖(无 crypto-browserify) | | 客户端 (client.mjs) | ~232 KB | 0 | | 服务端 (server.mjs) | ~191 KB | fastify-plugin(可选) | | runtime only (runtime.mjs) | ~17 KB | 0(只含 backend 抽象 + Web Crypto) |

对比之前 secure-crypto-top-sdk 的浏览器 bundle:

  • 之前:1.4MB (esbuild + 6 个 polyfill + 整个 crypto-browserify)
  • 现在:~226KB(自包含,无 polyfill,含分片 + 智能上传完整代码)
  • 节省 ~85%

版本体积变化:

  • v0.2.2 → v1.0.3 增长约 47KB(166KB → 213KB,主要是 MultipartClient + MultipartEndpoints ~38KB)
  • v1.0.3 → v1.0.5 再增约 13KB(213KB → 226KB,主要是 SmartClient + SmartEndpoints + 智能上传错误码)
  • v1.4.x 方案 A 减少约 4KB(226KB → ~222KB,删 requestBinary 方法 + BinaryRequestOptions interface + 相关 import)
  • v1.0.7 增 ~2KB(流式 hash 实现:用 backend.createHasher 替代一次性 buffer 累加,SDK 端 O(1) 内存)
  • v1.0.8 增 ~6KB(SmartClientConfig + maxFilesPerBatch 校验逻辑 + 类型导出,222KB → 232KB)

仍在"自包含零 polyfill" 范畴内。如果不需要分片 / 智能上传,可按 import { SecureClient } from '...' 只引入客户端入口。


🛠️ 开发

# 安装依赖
pnpm install

# 编译(快速,跳过 dts)
pnpm build

# 完整编译(含 dts,首次发布前)
pnpm build:full

# 类型检查
pnpm typecheck

# 跑测试
pnpm test

# 启动 demo 服务
pnpm demo:server

📝 融合点详细对照

吸收自 secure-crypto-sdk(老)的优点

  1. Web Crypto API 原生 - 浏览器走 crypto.subtle,零 polyfill
  2. zod 字段校验 - 入参/出参 schema 验证,字段级错误信息
  3. 启动密钥校验 - new SecureClient({...}) 立即抛错,不等运行时
  4. 响应验签 - verifyResponse: true 选项
  5. Web Crypto AES key 缓存 - aesKeyPromise 模式(在 web.ts 后端)

保留自 secure-crypto-top-sdk(新)的优点

  1. 双协议 - custom + top,客户端 protocol 选项切换
  2. 多算法 - SM4/SM3/AES-CBC/RSA-OAEP 国密混合
  3. 多 app 隔离 - apps[] 注册,KeyStore 派生
  4. Fastify 集成 - 零配置 hook
  5. 服务端 standalone 工具 - 不依赖 Fastify 也能用

新增(secure-crypto-top-zod-sdk 独有)

  1. CryptoBackend 抽象层 - 算法层零 import 'crypto',跨运行时
  2. 能力探测 - getCapabilities() / listSupportedAlgorithms() 诊断
  3. 算法过滤 - 浏览器自动跳过 Node-only 算法(SM4/SM3/MD5/RSA-OAEP)
  4. async API - 所有 encrypt/decrypt/sign/verify 全 async,统一行为
  5. 类型化枚举 - CipherAlgorithm / DigestAlgorithm 联合类型
  6. Vue 3 / React 18 一线集成示例 - 见上文"框架集成"章节

📜 Changelog

1.0.8(SmartClient 单次批量上限改为可配置)

本版主推:SDK 客户端 50 文件硬限制改为可配置(SmartClientConfig.maxFilesPerBatch + per-call upload({ maxFilesPerBatch })),治本解决"74 个文件被 1301 拒绝"问题 破坏性变更:无,完全向后兼容(默认仍是 50)

✨ 新增功能

  • SmartClientConfig 构造参数 —— new SmartClient(client, { maxFilesPerBatch: 200 }),对整个 SmartClient 实例生效
    // 方式 1:构造时配置(整个实例都生效)
    const smart = new SmartClient(client, { maxFilesPerBatch: 200 });
    
    // 方式 2:per-call 覆盖(更灵活,不影响其他调用)
    const smart = new SmartClient(client);
    await smart.upload(files, { maxFilesPerBatch: 100 });
    
    // 不传 / 旧代码:完全兼容,默认 50
    const smart = new SmartClient(client);
  • SmartUploadOptions.maxFilesPerBatch —— smart.upload(files, { maxFilesPerBatch: 100 }),per-call 覆盖构造器配置
  • SmartInitParams.maxFilesPerBatch —— client.smartInit({ files, maxFilesPerBatch: 200 }),低阶 API 也支持
  • 三层优先级:per-call opts > SmartClientConfig > SmartClient.DEFAULT_MAX_FILES_PER_BATCH = 50
  • SmartClient.DEFAULT_MAX_FILES_PER_BATCH 暴露为 static readonly 常量,业务可引用
  • 非法值防御:maxFilesPerBatch 非正整数 / 小数 / 0 / 负数 → 抛 INVALID_REQUEST,不会"静默回退 50"
  • 底层透传:SmartClient.upload() 把 effectiveMax 自动传给 client.smartInit(),SDK 内部两层校验(client.upload + client.smartInit)用同一阈值

🆕 为什么需要这个?

  • 之前:74 个文件上传 → 客户端直接 1301 INVALID_REQUEST: [SmartClient] 单次最多 50 个文件
  • 现在:new SmartClient(client, { maxFilesPerBatch: 200 }) → 200 个一把传,SDK 不再硬挡
  • 实际通过量仍受 server 端 secure-upload-fastify-sdksmartMaxFiles 控制(取较小值,server 端要同步调高)

📚 文档

  • ✅ README "智能上传" 章节加 3 种构造/调用示例 + 完整 maxFilesPerBatch 配置说明表(覆盖优先级 / 推荐值 / server 端协同)
  • ✅ 限流表"批量上限"行从硬编码 50 改为"v1.0.8+ 可配,默认 50"
  • ✅ 错误码表 INVALID_REQUEST 行更新说明
  • ✅ 体积表更新(222KB → 232KB,SmartClientConfig + 校验逻辑 + 类型导出)
  • ✅ CDN 链接升到 @1.0.8

🔧 内部

  • SmartClientprivate readonly defaultMaxFilesPerBatch 字段
  • SmartClient.upload() 入口先解 effectiveMax,非法值立即抛错
  • SmartEndpoints.smartInit 同步支持 maxFilesPerBatch 透传
  • client/index.ts + 主入口 src/index.ts 都 export SmartClientConfig 类型
  • ✅ d.ts 验证:SmartClientConfig / SmartUploadOptions.maxFilesPerBatch / SmartInitParams.maxFilesPerBatch 全部正确暴露

💡 升级建议:

  • 老项目用 new SmartClient(client) 没传配置 → 零改动直接升级,完全向后兼容(默认仍是 50)
  • 之前因为 50 限制被迫分批(>50 个文件拆 2 次调) → 用 maxFilesPerBatch: 200 一次传完,效率显著提升
  • 用了 client.smartInit() 直接调低阶 API → 也可以传 maxFilesPerBatch,SDK 内部校验更精准

1.0.7(SmartClient / MultipartClient 流式 hash,治本修 5×30MB OOM)

本版主推:SmartClient / MultipartClient 整文件 hash 改流式实现,SDK 端 O(1) 内存,治本修"5 个 30MB 文件并发上传进程消失(端口 8083 ECONNREFUSED)" 破坏性变更:无,完全向后兼容

🐛 治本修复(根因)

  • SmartClient.computeFileHash 改流式 —— 用 backend.createHasher 真增量实现
    • 之前:逐 4MB 块读入 + sha256.update,但旧实现内部把所有 chunk push 到 parts[] 数组,再 new Uint8Array(全部) + digest.update 一次算 → 3× size 内存峰值 × 并发数 → 5 个 30MB 并发 = 9×30MB 峰值 → Node 24 触发 OOM / native crash
    • 现在:backend.createHasher → 4MB 块读完立即 hasher.update(buf),buffer 立刻可被 GC 回收 → 真正的 O(1) 内存
    • 验证:5×30MB 文件上传 → RSS 仅 110.4MB(原 OOM 9×30MB 峰值)
  • MultipartClient 同步修复 —— computeFileHash 同样改流式,单文件大文件上传不再 OOM
  • Node 端:backend.createHashercrypto.createHash(原生 C 实现,O(1) 内部 state)
  • Web 端:backend.createHasher@noble/hashes/sha256 真增量(浏览器 Web Crypto 不支持流式 digest)

📚 文档

  • ✅ 体积表更新(222KB → ~224KB,流式 hash 实现)
  • ✅ v1.0.8 进一步扩到 232KB(SmartClientConfig + 校验逻辑)

💡 升级建议:

  • 所有用到 SmartClient.upload / MultipartClient.upload 的项目 → 强烈建议升级,彻底解决"5 文件 OOM"问题
  • 之前为了绕开 OOM 改的"手动分批"代码 → 现在可以放心一次传 ≥ 50 个文件(maxFilesPerBatch: 200)

本版主推:代码净清理,统一上传入口(SmartClient) 破坏性变更:SecureClient.requestBinary()POST /upload/{private,public,batch} 三个老端点已彻底删除(不是 Deprecation)。业务侧必须迁到 SmartClientMultipartClient配套后端:backend apps/upload-service/src/routes/upload.ts 已删除(489 行),/upload/smart/init + /multipart/* 是唯一上传入口。

❌ 删除清单

  • SecureClient.requestBinary() 方法 + BinaryRequestOptions interface(~120 行,含 binary 协议内部 envelope 拼装)
  • ✅ 服务端 /api/v1/upload/private /upload/public /upload/batch 三个端点(489 行,backend/apps/upload-service/src/routes/upload.ts 整个删除)
  • app.tsonSend Deprecation hook(老端点已删,警告无意义)

✅ 保留(SDK 内部依赖)

  • binary 协议本身(MultipartEndpoints.sendBinary/multipart/part 端点,SmartClient 内部复用)
  • MultipartClient + MultipartEndpoints + SmartClient + SmartEndpoints 全部保留
  • pickProtocol / getProtocol / parseKeyHex / parseSecret 等工具函数

🔧 内部

  • SecureClientgetProtocol import(原 requestBinary 唯一使用点)
  • ✅ 顶部 doc 注释加 v1.4.x 方案 A 历史说明
  • frontend-demo/upload.html 大改:从 2042 行精简到 ~660 行,只留 SmartClient 入口

📚 文档

  • ✅ README 顶部加 v1.4.x 方案 A 清理 警告
  • ✅ 新增 ## ⚠️ v1.4.x 已删除的接口(迁移必看) 章节
  • ## 📦 二进制上传 章节改名为 ## 🔌 Binary 协议(底层),标记 requestBinary 已删除
  • ✅ 智能上传章节"适用场景"表删除 client.requestBinary() 引用
  • ✅ 体积表更新(226KB → ~222KB)

💡 升级步骤:

  1. 搜代码里所有 client.requestBinaryclient.post('/upload/private')client.post('/upload/public')client.post('/upload/batch') 的引用
  2. 单文件 → 改 new MultipartClient(client).upload(fromBlob(file), { isPublic, session })
  3. 多文件批量 → 改 new SmartClient(client).upload(files.map(smartFromBlob), { isPublic, session })
  4. 重新 pnpm build SDK,pnpm install --force secure-crypto-top-zod-sdk,重启 gateway

1.0.5(智能上传 SmartClient + 私有批量 session 透传修复)

本版主推:一站式 SmartClient(服务端智能分流,任意大小/任意数量文件) 破坏性变更:无,纯新增 + 1 个 bug 修复,完全向后兼容

✨ 新增功能

  • SmartClient 高阶封装 —— new SmartClient(client).upload(files, { isPublic, session }),N 个文件 + 任意大小 1 行调用
    • 整文件 SHA-256 秒传(命中直接返回 fileId)
    • 服务端按 totalSize 智能分流:≤1MB ONE_SHOT(2 RTT) / 1MB~1GB 1MB·part / 1GB~10GB 5MB·part / >10GB 10MB·part
    • 单文件内 part 并发(suggestedConcurrency 由 server 返回)
    • 失败重试(指数退避,默认 3 次,500ms 起步)
    • 进度回调(onProgress({ fileIndex, fileBytesUploaded, totalBytesUploaded, filePhase, ... }),perFile + perPart + aggregate 三层聚合)
    • 中途取消(AbortSignal,整批粒度)
    • 跨环境(浏览器 / Node / RN / Web Worker 一套代码,零运行时依赖)
    • 混合批量(部分 isPublic=true 部分 isPublic=false)按 file.isPublic 各自鉴权
  • SecureClient.smartInit() 低阶方法 —— 通过 module augmentation 挂载,对应 /api/v1/upload/smart/init
    • 显式传 protocol: 'top',避免 defaultProtocol='binary' 时把 JSON 端点当 binary 发
    • SmartInitParams.sessionx-secure-session header(私有批量透传 JWT)
    • 完整类型:SmartInitParams / SmartInitResponse / SmartInitFileItem / SmartInitFileResult
  • 适配器:smartFromBlob(file) / smartFromBuffer(buf, name) —— 与 fromBlob / fromBuffer 对称

🐛 修复

  • 私有批量 session 透传 —— SmartClient.upload()opts.session 透传给 client.smartInit(),服务端才能从 x-secure-session header 拿到 userId,否则 5GB 私有文件批量时全部 1503 BINARY_AUTH_REQUIRED
  • SmartEndpoints.sendTop 显式传 protocol —— client.request(..., { protocol: 'top' }),避免 client 配置 defaultProtocol='binary' 时把 /smart/init 错误地当 binary 发

📚 文档

  • ✅ 新增 "🧠 智能上传 (Smart Upload)" 章节(API + 服务端策略表 + 鉴权 + 错误码 + 与 MultipartClient 对比)
  • ✅ 体积表更新(213KB → 226KB,含 SmartClient)
  • ✅ CDN 链接更新到 @1.0.5

🔧 内部

  • SmartClient 内部:init 用 client.smartInit,part/complete/abort/status 复用 client.multipart*(避免代码重复)
  • ConcurrencyPool + retryWithBackoff 抽成模块级工具(与 MultipartClient 共享设计)
  • ✅ 进度回调统一为 SmartProgress(perFile + perPart + aggregate 三层聚合)

💡 **升级建议