Skip to content

Ministak

Ministak 是一个轻量的 Vue 全栈框架:

text
Vue 客户端 SPA + Fastify 服务端 + Server Action

Vue 和 Vite 负责浏览器应用,Fastify 负责服务端,Server Action 连接两端。框架不包装 Vue,也不隐藏 Fastify 实例,熟悉这些工具的开发者可以继续使用原生 API。

创建项目

bash
pnpm create ministak my-app
cd my-app
pnpm dev

打开终端显示的地址即可开始开发。项目的常用文件如下:

text
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',然后导出具名异步函数:

ts
// src/actions.ts
'use server'

let count = 0

export async function getCounter() {
  return count
}

export async function incrementCounter() {
  count += 1
  return count
}

Vue 组件直接从原文件导入并调用:

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 传输:

ts
// 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,
  }
}
ts
const todo = await createTodo('学习 Ministak')

使用 Fastify

src/server.ts 直接创建并默认导出 Fastify 实例:

ts
// 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 app

Fastify 的选项、Hook、插件、装饰器、错误处理和普通路由都可以照常使用。 服务端入口不调用 listen(),监听端口和开发热重启由 Ministak 管理。

拦截和鉴权

Fastify Hook 可以通过 request.serverAction 判断当前请求是否为 Action:

ts
// 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 app

request.serverAction.name 始终使用可读的 项目相对路径#导出名,例如 src/actions.ts#incrementCounter。 可以精确匹配一个 Action,也可以使用 startsWith() 统一处理某个目录或文件。 普通请求的 request.serverActionnull

生产环境使用不可读的传输 ID,但传输 ID 不是权限机制。 所有 Action 都应视为可以从外部调用,鉴权和参数校验必须在服务端完成。

请求上下文

Action 可以通过 getActionContext() 获取当前 Fastify 请求和响应:

ts
// 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',
  )
}

上下文还包含 requestactionNamerequestId,可以用于读取用户信息、 设置 Cookie 或关联服务端日志。

业务异常

需要公开给客户端的业务异常使用 ActionError

ts
// 服务端
throw new ActionError('待办事项不存在', {
  code: 'NOT_FOUND',
  status: 404,
})

客户端会收到 ServerActionError

ts
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 时,它和普通异步函数一样:

ts
const count = await incrementCounter()

需要在页面显示请求状态时,可以绑定一个 Vue ref:

vue
<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:

ts
// 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 提供内存文件和文件流两种方式:

客户端参数服务端参数适用场景
fileFile小文件,直接读取最方便
filesFile[]少量小文件,需要任意顺序访问
fileStream(file)FileStream单个大文件,不希望完整载入内存
fileStreams(files)FileStreams多个大文件,逐个处理并控制内存占用

直接传入 FileFile[] 时,框架会先把完整文件读入服务端内存:

ts
// 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(),让服务端边接收边处理:

ts
// 客户端
import { fileStreams } from 'ministak/client'
import { uploadImages } from './actions'

await uploadImages(fileStreams(input.files ?? []))
ts
// 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() 丢弃剩余内容。

如果整个文件集合都不再需要,但还要读取位于它后面的其他流参数, 可以一次跳过集合中的当前文件和剩余文件:

ts
'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

powershell
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":[]}'

默认请求地址是 /_actionsargs 是传给 Action 的位置参数数组。 开发环境的 x-action-id 使用可读的 项目相对路径#导出名,因此可以直接手写。生产环境使用由构建生成的 a_ 开头不透明 ID,不能把开发 ID 写进生产请求。

传输 ID 只用于网络请求。Fastify 中的 request.serverAction.name 在开发和生产环境始终是可读的 项目相对路径#导出名

文件 Action 不需要手工拼接 multipart,可以在 TypeScript 脚本中直接调用:

ts
// 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

bash
pnpm add -D tsx
pnpm exec tsx --conditions=react-server test-upload.ts

--conditions=react-server 用于让 Action 依赖中的 server-only 在 Node 脚本中正常工作。FileFile[]fileStream() 也可以用相同方式传入。

使用 Vitest 长期测试

安装 Vitest:

bash
pnpm add -D vitest

如果 Action 的依赖使用了 server-only,添加以下配置:

ts
// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  resolve: {
    conditions: ['react-server'],
  },
  ssr: {
    resolve: {
      conditions: ['react-server'],
      externalConditions: ['react-server'],
    },
  },
})

Action 是具名异步函数,可以直接导入测试,文件参数的写法与临时脚本相同:

ts
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

ts
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

ts
// src/database.ts
import 'server-only'

export const databaseUrl = process.env.DATABASE_URL

客户端依赖链触及该模块时,构建会直接报错。Action 模块及其服务端依赖不会进入客户端产物。

环境变量

服务端通过 process.env 读取环境变量:

ts
const databaseUrl = process.env.DATABASE_URL

客户端通过 import.meta.env 读取变量,只有 VITE_ 开头的名称会进入浏览器:

ts
const apiUrl = import.meta.env.VITE_API_URL

不要在 VITE_ 变量中存放密钥。开发环境读取 .env.development, 生产环境读取 .env.production,本机私密配置可以放在对应的 .local 文件中。

检查构建边界

在发布前查看客户端和服务端分别包含哪些文件:

bash
pnpm inspect

该命令使用真实生产构建生成文件树,但不会写入 dist。 它还会显示环境变量的名称、来源和可见范围,变量值始终隐藏。

构建和运行

bash
pnpm build
pnpm start

构建产物:

text
dist/client    Vue SPA 静态资源
dist/server    Fastify、Action 和服务端依赖

生产环境运行 pnpm start,由 Fastify 同时提供页面、普通路由和 Server Action。

下一步

Ministak 官方教程