npm package: @holix/static
Description: Static file middleware for @holix/router with pattern matching and blacklist support
Author: zhaogongchengsi
Latest version: 0.0.6

# @holix/static

静态文件服务中间件，用于 `@holix/router` 的自定义协议路由。支持文件过滤、缓存控制、目录列表等功能。

## 特性

- ✅ 基于 glob 模式的黑名单/白名单文件过滤
- ✅ 智能缓存控制（ETag、Cache-Control）
- ✅ 目录列表自动生成
- ✅ 路径遍历攻击防护
- ✅ 符号链接控制
- ✅ 自定义 MIME 类型
- ✅ 404/错误回调处理
- ✅ 40+ 内置 MIME 类型

## 安装

```bash
pnpm add @holix/static
```

## 基本使用

```typescript
import path from 'node:path'
import { createRouter } from '@holix/router'
import { createStaticMiddleware } from '@holix/static'

const router = createRouter()
const staticDir = path.resolve(__dirname, './public')

// 创建静态文件中间件
const staticMiddleware = createStaticMiddleware({
  root: staticDir,
  prefix: '/_static', // 可选，URL 前缀
})

// 注册中间件
router.use(staticMiddleware)

// 现在可以访问:
// holix://app/_static/style.css
// holix://app/_static/images/logo.png
```

## 配置选项

### StaticMiddlewareOptions

```typescript
interface StaticMiddlewareOptions {
  /** 静态文件根目录（绝对路径） */
  root: string

  /** URL 路径前缀，例如 '/_static' 或 '/assets' */
  prefix?: string

  /** 是否启用缓存（默认: true） */
  cache?: boolean

  /** 缓存最大时长（秒），默认: 31536000 (1 年) */
  maxAge?: number

  /** 黑名单模式列表（支持 glob 语法） */
  blacklist?: string[]

  /** 白名单模式列表（支持 glob 语法，优先级高于黑名单） */
  whitelist?: string[]

  /** 是否启用目录列表（默认: false） */
  index?: boolean

  /** 索引文件名（默认: ['index.html']） */
  indexFiles?: string[]

  /** 是否跟随符号链接（默认: false） */
  followSymlinks?: boolean

  /** 自定义 MIME 类型映射 */
  mimeTypes?: Record<string, string>

  /** 404 处理函数 */
  onNotFound?: (ctx: ExtendedRouteContext, filePath: string) => void

  /** 错误处理函数 */
  onError?: (ctx: ExtendedRouteContext, error: Error) => void

  /** 忽略路径模式列表（支持 glob 语法），用于解决 API 请求和静态路由冲突 */
  ignorePaths?: string[]

  /** 路径重写函数，用于 SPA 路由回退 */
  rewrite?: (requestPath: string, ctx: ExtendedRouteContext) => string | null | undefined

  /** 是否启用默认的 SPA 回退行为（默认: true） */
  fallbackToIndex?: boolean
}
```

## 使用示例

### 1. 基本静态文件服务

```typescript
import { createStaticMiddleware } from '@holix/static'

const middleware = createStaticMiddleware({
  root: '/path/to/public',
})
```

### 2. SPA 应用（非 hash 路由支持）

对于单页应用，当访问不存在的路由时，应返回 index.html：

```typescript
const middleware = createStaticMiddleware({
  root: './dist',
  fallbackToIndex: true, // 默认就是 true，文件不存在时返回 index.html
})

// 访问 holix://app/user/profile
// → 没有扩展名，判断为路由 → 自动返回 index.html
// → 前端路由接管显示用户页面

// 访问 holix://app/nonexistent.js
// → 有扩展名，判断为静态文件 → 返回 404
// → 防止错误地返回 index.html 导致资源加载失败
```

**重要说明**：SPA 回退只对没有文件扩展名的路径生效。如果请求的是具体的静态文件（如 `.js`、`.css`、`.png` 等），即使文件不存在也会返回 404，而不是回退到 index.html。这可以避免前端资源加载时出现错误。

### 3. 忽略 API 路径（避免与静态路由冲突）

```typescript
const middleware = createStaticMiddleware({
  root: './public',
  ignorePaths: ['/api/**', '/graphql'], // API 请求直接跳过
})

router.use(middleware)

// API 路由
router.post('/api/users', async (ctx) => {
  // 处理 API 请求，不会被静态中间件拦截
})
```

### 4. 自定义路径重写

```typescript
const middleware = createStaticMiddleware({
  root: './dist',
  rewrite: (requestPath, ctx) => {
    // 所有 /app/* 路径都返回 index.html
    if (requestPath.startsWith('/app/')) {
      return '/index.html'
    }

    // 其他路径按原样处理（返回 null）
    return null
  },
})
```

### 5. 完整的 SPA + API 配置

```typescript
import { createRouter } from '@holix/router'
import { createStaticMiddleware } from '@holix/static'

const router = createRouter()

// 静态文件中间件
router.use(createStaticMiddleware({
  root: './dist',
  ignorePaths: [
    '/api/**', // 忽略所有 API 请求
    '/auth/**', // 忽略认证路由
    '/graphql', // 忽略 GraphQL
  ],
  fallbackToIndex: true, // SPA 路由回退
}))

// API 路由
router.get('/api/users', async (ctx) => {
  ctx.json({ users: [] })
})

router.post('/api/login', async (ctx) => {
  // 登录逻辑
})

// 前端访问：
// - /user/123 → index.html（前端路由）
// - /about → index.html（前端路由）
// - /api/users → API 处理
// - /style.css → 静态文件
```

### 6. 黑名单/白名单过滤

### 2. 使用 URL 前缀

```typescript
const middleware = createStaticMiddleware({
  root: '/path/to/public',
  prefix: '/_static',
})

// 访问: holix://app/_static/style.css
// 映射到: /path/to/public/style.css
```

### 3. 文件过滤 - 黑名单

```typescript
const middleware = createStaticMiddleware({
  root: '/path/to/public',
  blacklist: [
    '**/.git/**', // 屏蔽 .git 目录
    '**/.env*', // 屏蔽 .env 文件
    '**/config.json', // 屏蔽配置文件
  ],
})
```

**默认黑名单** (自动包含):
- `**/.git/**`
- `**/.svn/**`
- `**/node_modules/**`
- `**/.env*`
- `**/package-lock.json`
- `**/pnpm-lock.yaml`
- `**/yarn.lock`

### 4. 文件过滤 - 白名单

白名单优先级高于黑名单。只有匹配白名单的文件才允许访问。

```typescript
const middleware = createStaticMiddleware({
  root: '/path/to/public',
  whitelist: [
    '**/*.js', // 只允许 JS 文件
    '**/*.css', // 只允许 CSS 文件
    '**/*.png', // 只允许 PNG 图片
    '**/*.jpg', // 只允许 JPG 图片
  ],
})
```

### 5. 启用目录列表

```typescript
const middleware = createStaticMiddleware({
  root: '/path/to/public',
  index: true, // 启用目录列表
  indexFiles: ['index.html', 'index.htm'], // 优先查找的索引文件
})
```

### 6. 禁用缓存（开发模式）

```typescript
const middleware = createStaticMiddleware({
  root: '/path/to/public',
  cache: false, // 禁用缓存
})
```

### 7. 自定义缓存时长

```typescript
const middleware = createStaticMiddleware({
  root: '/path/to/public',
  cache: true,
  maxAge: 86400, // 缓存 1 天 (单位: 秒)
})
```

### 8. 自定义 MIME 类型

```typescript
const middleware = createStaticMiddleware({
  root: '/path/to/public',
  mimeTypes: {
    '.md': 'text/markdown',
    '.ts': 'text/typescript',
  },
})
```

### 9. 错误处理回调

```typescript
const middleware = createStaticMiddleware({
  root: '/path/to/public',
  onNotFound: (ctx, filePath) => {
    console.log('File not found:', filePath)
    ctx.res.statusCode = 404
    ctx.res.setHeader('Content-Type', 'application/json')
    ctx.res.end(JSON.stringify({ error: 'File not found' }))
  },
  onError: (ctx, error) => {
    console.error('Static file error:', error)
    ctx.res.statusCode = 500
    ctx.res.end('Internal Server Error')
  },
})
```

## Glob 模式语法

使用 [micromatch](https://github.com/micromatch/micromatch) 进行模式匹配：

```typescript
// 基本通配符
'*.js' // 匹配任何 .js 文件
'script.js' // 精确匹配

// 目录通配符
'**/*.css' // 匹配所有子目录中的 .css 文件
'src/**/*.ts' // 匹配 src 下所有 .ts 文件

// 字符集
'file[0-9].txt' // 匹配 file0.txt, file1.txt, ...
'*.{js,ts}' // 匹配 .js 或 .ts 文件

// 否定模式
'!test/**' // 排除 test 目录
```

## 缓存策略

### 1. ETag 缓存

中间件自动为每个文件生成 ETag（基于文件修改时间和大小）：

```
ETag: "5f7b8c9d-1234"
```

客户端下次请求时携带 `If-None-Match`，如果 ETag 匹配则返回 `304 Not Modified`。

### 2. 不可变缓存

对于包含 hash 的文件（如 `bundle-abc123.js`），自动设置不可变缓存：

```
Cache-Control: public, max-age=31536000, immutable
```

### 3. 普通缓存

其他文件使用标准缓存：

```
Cache-Control: public, max-age=31536000
```

## 安全特性

### 1. 路径遍历防护

自动检测并阻止路径遍历攻击：

```
GET /../../../etc/passwd  ❌ 返回 400
GET /%2e%2e%2f%2e%2e%2f  ❌ 返回 400
```

### 2. 符号链接控制

默认不跟随符号链接，防止访问根目录外的文件：

```typescript
createStaticMiddleware({
  root: '/var/www',
  followSymlinks: false, // 默认值
})
```

### 3. 默认黑名单

自动屏蔽敏感文件和目录（`.git`, `.env`, `node_modules` 等）。

## 内置 MIME 类型

支持 40+ 常见 MIME 类型：

| 扩展名 | MIME 类型 |
|--------|-----------|
| `.html`, `.htm` | `text/html` |
| `.css` | `text/css` |
| `.js`, `.mjs` | `application/javascript` |
| `.json` | `application/json` |
| `.png` | `image/png` |
| `.jpg`, `.jpeg` | `image/jpeg` |
| `.svg` | `image/svg+xml` |
| `.woff2` | `font/woff2` |
| `.mp4` | `video/mp4` |
| ... | ... |

完整列表请参考源码中的 `DEFAULT_MIME_TYPES`。

## 最佳实践

### 开发环境

```typescript
createStaticMiddleware({
  root: './public',
  cache: false, // 禁用缓存，便于调试
  index: true, // 启用目录列表
})
```

### 生产环境

```typescript
createStaticMiddleware({
  root: './dist',
  cache: true, // 启用缓存
  maxAge: 31536000, // 1 年缓存
  blacklist: [ // 严格的黑名单
    '**/.git/**',
    '**/.env*',
    '**/config/**',
  ],
})
```

### Electron 应用

```typescript
import path from 'node:path'
import { app } from 'electron'

const staticDir = app.isPackaged
  ? path.join(process.resourcesPath, 'public')
  : path.join(__dirname, '../public')

createStaticMiddleware({
  root: staticDir,
  prefix: '/_assets',
  cache: true,
})
```

## 与 router-ssr 集成

配合 `@holix/router-ssr` 使用，服务打包后的 chunk 文件：

```typescript
import { createRouter } from '@holix/router'
import { createSSRMiddleware } from '@holix/router-ssr'
import { createStaticMiddleware } from '@holix/static'
import path from 'node:path'

const router = createRouter()

// 1. 静态文件中间件（服务 _chunks）
router.use(createStaticMiddleware({
  root: path.resolve(__dirname, './dist'),
  cache: true,
}))

// 2. SSR 中间件（处理路由）
router.use(createSSRMiddleware({
  routes: [...],
  framework: 'vue',
}))
```

访问流程：
1. `holix://app/about` → SSR 渲染 HTML
2. `holix://app/_chunks/about-C5TTlyQe.js` → 静态文件服务

## 工具函数

包提供了一些实用工具函数：

```typescript
import {
  formatFileSize,
  getExtension,
  isHiddenFile,
  isSafePath,
  matchPatterns,
} from '@holix/static'

// 匹配 glob 模式
matchPatterns('src/index.ts', ['**/*.ts']) // true

// 检测路径安全性
isSafePath('../../../etc/passwd') // false

// 格式化文件大小
formatFileSize(1024) // '1.00 KB'

// 获取扩展名
getExtension('script.js') // '.js'

// 检测隐藏文件
isHiddenFile('.gitignore') // true
```

## API 参考

### createStaticMiddleware(options)

创建静态文件中间件。

- **参数**: `StaticMiddlewareOptions`
- **返回**: `StaticMiddleware` 函数

### matchPatterns(path, patterns)

检查路径是否匹配 glob 模式列表。

- **参数**:
  - `path`: 要检查的路径
  - `patterns`: glob 模式数组
- **返回**: `boolean`

### isSafePath(path)

检查路径是否安全（无路径遍历）。

- **参数**: `path` - 要检查的路径
- **返回**: `boolean`

### formatFileSize(bytes)

格式化文件大小为人类可读格式。

- **参数**: `bytes` - 字节数
- **返回**: `string` (例如: `'1.50 MB'`)

### getExtension(filename)

获取文件扩展名（小写）。

- **参数**: `filename` - 文件名
- **返回**: `string` (例如: `'.js'`)

### isHiddenFile(filename)

检查文件是否为隐藏文件（以 `.` 开头）。

- **参数**: `filename` - 文件名
- **返回**: `boolean`

## License

MIT