Ministak
Ministak 是一个轻量的 Vue 全栈框架:
Vue 客户端 SPA + Fastify 服务端 + Server ActionVue 和 Vite 负责浏览器应用,Fastify 负责服务端,Server Action 连接两端。框架不包装 Vue,也不隐藏 Fastify 实例,熟悉这些工具的开发者可以继续使用原生 API。
创建项目
pnpm create ministak my-app
cd my-app
pnpm dev打开终端显示的地址即可开始开发。项目的常用文件如下:
src/
├─ App.vue Vue 页面
├─ main.ts 客户端入口
├─ actions.ts Server Action
└─ server.ts Fastify 服务端入口客户端、服务端和共享 TypeScript 都放在 src:
- Vue 组件和
src/main.ts的依赖运行在客户端。 - 文件顶部包含
'use server'的模块是 Server Action。 src/server.ts默认导出 Fastify 实例。- 普通模块可以由客户端、服务端或两端共享。
server-only用于明确禁止模块进入客户端。
Server Action
在模块顶部声明 'use server',然后导出具名异步函数:
// src/actions.ts
'use server'
let count = 0
export async function getCounter() {
return count
}
export async function incrementCounter() {
count += 1
return count
}Vue 组件直接从原文件导入并调用:
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { getCounter, incrementCounter } from './actions'
const count = ref<number | null>(null)
onMounted(async () => {
count.value = await getCounter()
})
async function increment() {
count.value = await incrementCounter()
}
</script>
<template>
<p>{{ count }}</p>
<button @click="increment">+1</button>
</template>开发时仍然拥有 TypeScript 类型检查、参数提示和源码跳转。客户端构建时, Action 函数会被转换成 RPC 请求,真正的函数只在服务端执行。
普通参数和返回值通过 JSON 传输:
// src/actions.ts
'use server'
export interface Todo {
id: number
title: string
completed: boolean
}
export async function createTodo(title: string): Promise<Todo> {
return {
id: Date.now(),
title,
completed: false,
}
}const todo = await createTodo('学习 Ministak')使用 Fastify
src/server.ts 直接创建并默认导出 Fastify 实例:
// src/server.ts
import Fastify from 'fastify'
const app = Fastify({
logger: true,
})
app.addHook('onRequest', async (request) => {
request.log.info('收到请求')
})
app.get('/api/health', async () => ({ ok: true }))
export default appFastify 的选项、Hook、插件、装饰器、错误处理和普通路由都可以照常使用。 服务端入口不调用 listen(),监听端口和开发热重启由 Ministak 管理。
拦截和鉴权
Fastify Hook 可以通过 request.serverAction 判断当前请求是否为 Action:
// src/server.ts
import Fastify from 'fastify'
import { ActionError } from 'ministak/server'
const app = Fastify()
app.addHook('onRequest', async (request) => {
if (
request.serverAction?.name ===
'src/actions.ts#incrementCounter' &&
!request.headers.cookie
?.split(';')
.some(
(cookie) => cookie.trim() === 'session=logged-in',
)
) {
throw new ActionError('请先登录', {
code: 'UNAUTHORIZED',
status: 401,
})
}
})
export default apprequest.serverAction.name 始终使用可读的 项目相对路径#导出名,例如 src/actions.ts#incrementCounter。 可以精确匹配一个 Action,也可以使用 startsWith() 统一处理某个目录或文件。 普通请求的 request.serverAction 为 null。
生产环境使用不可读的传输 ID,但传输 ID 不是权限机制。 所有 Action 都应视为可以从外部调用,鉴权和参数校验必须在服务端完成。
请求上下文
Action 可以通过 getActionContext() 获取当前 Fastify 请求和响应:
// src/actions.ts
'use server'
import { getActionContext } from 'ministak/server'
export async function login() {
const { reply } = getActionContext()
reply.header(
'set-cookie',
'session=logged-in; Path=/; HttpOnly; SameSite=Lax',
)
}上下文还包含 request、actionName 和 requestId,可以用于读取用户信息、 设置 Cookie 或关联服务端日志。
业务异常
需要公开给客户端的业务异常使用 ActionError:
// 服务端
throw new ActionError('待办事项不存在', {
code: 'NOT_FOUND',
status: 404,
})客户端会收到 ServerActionError:
import { ServerActionError } from 'ministak/client'
try {
await deleteTodo(id)
} catch (error) {
if (error instanceof ServerActionError) {
console.log(error.message, error.code, error.status)
return
}
throw error
}其他异常只会写入服务端日志,客户端统一收到 INTERNAL_ERROR, 避免泄露堆栈、数据库信息或密钥。
请求状态
直接等待 Action 时,它和普通异步函数一样:
const count = await incrementCounter()需要在页面显示请求状态时,可以绑定一个 Vue ref:
<script setup lang="ts">
import { ref } from 'vue'
import { incrementCounter } from './actions'
const count = ref<number | null>(null)
const loading = ref(false)
async function increment() {
count.value = await incrementCounter().bindLoading(loading)
}
</script>
<template>
<button :disabled="loading" @click="increment">
{{ loading ? '提交中' : '+1' }}
</button>
</template>每次调用都会创建独立请求。Action 在第一次被等待时开始执行, 同一个请求被多次等待也只会执行一次。多个并发请求绑定到同一个 ref 时, 它会在全部请求结束后恢复为 false。
客户端 Action Hook
需要统一设置请求头、转换响应或处理异常时,可以设置全局 Hook:
// src/main.ts
import {
ServerActionError,
setServerActionHooks,
} from 'ministak/client'
setServerActionHooks({
onRequest({ headers }) {
headers.set('x-trace-id', crypto.randomUUID())
},
onResponse({ data }) {
return data
},
onError({ error }) {
if (
error instanceof ServerActionError &&
error.status === 401
) {
location.assign('/login')
return
}
throw error
},
})onRequest 可以修改参数和请求头;onResponse 的返回值会替换原始结果; onError 正常返回表示异常已处理,抛出异常则继续失败。三个 Hook 都支持异步函数。
文件上传
Ministak 提供内存文件和文件流两种方式:
| 客户端参数 | 服务端参数 | 适用场景 |
|---|---|---|
file | File | 小文件,直接读取最方便 |
files | File[] | 少量小文件,需要任意顺序访问 |
fileStream(file) | FileStream | 单个大文件,不希望完整载入内存 |
fileStreams(files) | FileStreams | 多个大文件,逐个处理并控制内存占用 |
直接传入 File 或 File[] 时,框架会先把完整文件读入服务端内存:
// src/actions.ts
'use server'
export async function uploadImagesInMemory(files: File[]) {
return Promise.all(
files.map(async (file) => ({
name: file.name,
size: (await file.arrayBuffer()).byteLength,
})),
)
}这种方式没有读取顺序限制,也不需要 skip()。文件较大或数量较多时, 改用 fileStream() 或 fileStreams(),让服务端边接收边处理:
// 客户端
import { fileStreams } from 'ministak/client'
import { uploadImages } from './actions'
await uploadImages(fileStreams(input.files ?? []))// src/actions.ts
'use server'
import type { FileStreams } from 'ministak'
export async function uploadImages(files: FileStreams) {
const uploaded = []
for await (const file of files) {
if (!file.type.startsWith('image/')) {
await file.skip()
continue
}
let size = 0
for await (const chunk of file.stream) {
size += chunk.byteLength
// 也可以在这里把 chunk 写入对象存储或磁盘
}
uploaded.push({ name: file.name, size })
}
return uploaded
}FileStreams 是只能按顺序迭代一次的异步可迭代对象,每次提供一个 FileStream。框架不会自动把文件写入磁盘,文件名和类型也来自客户端, 应用仍需自行校验并决定如何保存。
为什么需要 skip()
同一次 multipart 请求中的文件内容共用一条网络流,并按顺序到达。 服务端不能越过前一个文件直接读取后一个文件,必须先读取或丢弃前一个文件的剩余内容。 因此每个 FileStream 都必须完成以下操作之一,才能继续下一个:
- 完整读取
file.stream。 - 确定不需要该文件时执行
await file.skip()。
skip() 不会取消整个上传请求,也不会让已经发送的字节消失; 它会持续读取并直接丢弃当前文件的剩余内容,使 multipart 解析器能够到达下一个文件, 同时避免把这些内容完整保存在内存中。
如果前一个文件尚未处理就读取后面的文件,Ministak 会立即抛出 FILE_STREAM_ORDER,避免请求无期限等待。
skip() 主要用于已经根据文件名、类型或业务条件决定拒绝当前文件, 但仍要继续处理同一次请求中的后续文件。即使已经读取了部分内容, 停止读取时也应调用 await file.skip() 丢弃剩余内容。
如果整个文件集合都不再需要,但还要读取位于它后面的其他流参数, 可以一次跳过集合中的当前文件和剩余文件:
'use server'
import type { FileStream, FileStreams } from 'ministak'
export async function useAvatarOnly(
attachments: FileStreams,
avatar: FileStream,
) {
await attachments.skip()
const bytes = await new Response(avatar.stream).arrayBuffer()
return bytes.byteLength
}使用 for await...of 时,break 会自动跳过集合中剩余的文件。 如果 Action 已经准备返回或抛出异常,也不需要手动调用 skip(); Ministak 会在结束请求前自动清理所有未读取的文件流。
测试
以下方式都用于开发环境,不需要模拟浏览器操作:
| 目的 | 方式 |
|---|---|
| 一次性测试普通 Action | 使用 curl 发送真实请求 |
| 一次性测试文件 Action | 使用 TypeScript 脚本直接调用 |
| 长期测试 Action 逻辑 | 使用 Vitest 直接调用 |
| 长期测试 Hook、鉴权和请求上下文 | 使用 Vitest 启动开发服务器后通过 fetch 请求 |
一次性测试
不含文件的 Action 可以直接使用 curl:
curl.exe http://127.0.0.1:5173/_actions `
-X POST `
-H "content-type: application/json" `
-H "x-action-id: src/actions.ts#getCounter" `
--data '{"args":[]}'默认请求地址是 /_actions,args 是传给 Action 的位置参数数组。 开发环境的 x-action-id 使用可读的 项目相对路径#导出名,因此可以直接手写。生产环境使用由构建生成的 a_ 开头不透明 ID,不能把开发 ID 写进生产请求。
传输 ID 只用于网络请求。Fastify 中的 request.serverAction.name 在开发和生产环境始终是可读的 项目相对路径#导出名。
文件 Action 不需要手工拼接 multipart,可以在 TypeScript 脚本中直接调用:
// test-upload.ts
import { readFile } from 'node:fs/promises'
import { fileStreams } from 'ministak/client'
import { uploadImages } from './src/actions'
const file = new File(
[new Uint8Array(await readFile('./avatar.png'))],
'avatar.png',
{ type: 'image/png' },
)
console.log(await uploadImages(fileStreams([file])))安装并执行 tsx:
pnpm add -D tsx
pnpm exec tsx --conditions=react-server test-upload.ts--conditions=react-server 用于让 Action 依赖中的 server-only 在 Node 脚本中正常工作。File、File[] 和 fileStream() 也可以用相同方式传入。
使用 Vitest 长期测试
安装 Vitest:
pnpm add -D vitest如果 Action 的依赖使用了 server-only,添加以下配置:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
resolve: {
conditions: ['react-server'],
},
ssr: {
resolve: {
conditions: ['react-server'],
externalConditions: ['react-server'],
},
},
})Action 是具名异步函数,可以直接导入测试,文件参数的写法与临时脚本相同:
import { expect, test } from 'vitest'
import { fileStreams } from 'ministak/client'
import { uploadImages } from '../src/actions'
test('上传图片', async () => {
const file = new File(
['image'],
'avatar.png',
{ type: 'image/png' },
)
await expect(
uploadImages(fileStreams([file])),
).resolves.toEqual([
{
name: 'avatar.png',
size: 5,
},
])
})执行 pnpm exec vitest run 即可运行测试。
这种方式会执行真实的 Action 函数、数据库代码和文件处理逻辑,但不会经过 Action 网络请求、Fastify Hook 或 getActionContext()。
需要测试完整请求时,可以让 Vitest 自动启动开发服务器,再使用 fetch:
import {
afterAll,
beforeAll,
expect,
test,
} from 'vitest'
import {
createDevServer,
type MinistakDevServer,
} from 'ministak/dev'
let server: MinistakDevServer
beforeAll(async () => {
server = await createDevServer({
root: process.cwd(),
port: 0,
})
})
afterAll(async () => {
await server.close()
})
test('未登录不能增加计数器', async () => {
const response = await fetch(
`${server.url}/_actions`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
'x-action-id':
'src/actions.ts#incrementCounter',
},
body: JSON.stringify({ args: [] }),
},
)
expect(response.status).toBe(401)
expect(await response.json()).toMatchObject({
ok: false,
error: {
code: 'UNAUTHORIZED',
},
})
})直接调用适合测试应用自己的业务和文件处理;通过 fetch 请求适合测试 Fastify Hook、鉴权、请求上下文、状态码和异常响应。文件传输协议由 Ministak 自身的测试保证,应用通常只需直接测试收到文件后的处理行为。
服务端专用模块
数据库连接、文件系统和私密配置等模块可以使用 server-only:
// src/database.ts
import 'server-only'
export const databaseUrl = process.env.DATABASE_URL客户端依赖链触及该模块时,构建会直接报错。Action 模块及其服务端依赖不会进入客户端产物。
环境变量
服务端通过 process.env 读取环境变量:
const databaseUrl = process.env.DATABASE_URL客户端通过 import.meta.env 读取变量,只有 VITE_ 开头的名称会进入浏览器:
const apiUrl = import.meta.env.VITE_API_URL不要在 VITE_ 变量中存放密钥。开发环境读取 .env.development, 生产环境读取 .env.production,本机私密配置可以放在对应的 .local 文件中。
检查构建边界
在发布前查看客户端和服务端分别包含哪些文件:
pnpm inspect该命令使用真实生产构建生成文件树,但不会写入 dist。 它还会显示环境变量的名称、来源和可见范围,变量值始终隐藏。
构建和运行
pnpm build
pnpm start构建产物:
dist/client Vue SPA 静态资源
dist/server Fastify、Action 和服务端依赖生产环境运行 pnpm start,由 Fastify 同时提供页面、普通路由和 Server Action。