Compare commits
14 Commits
feature/te
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
2055b62afd | |
|
|
6037bf2bd6 | |
|
|
d6f222c2e0 | |
|
|
0028091f34 | |
|
|
0bcba03393 | |
|
|
6234c27138 | |
|
|
9f4ee690db | |
|
|
a272c62b97 | |
|
|
18a46c9af5 | |
|
|
5a7155ecbc | |
|
|
0ac78360a1 | |
|
|
7725cf1f65 | |
|
|
d9d6404218 | |
|
|
1710562c54 |
|
|
@ -0,0 +1,18 @@
|
||||||
|
# ===== EternalAI 环境变量配置 =====
|
||||||
|
# 复制此文件为 .env 并填写真实值
|
||||||
|
# cp .env.example .env
|
||||||
|
|
||||||
|
# 数据库连接字符串
|
||||||
|
# 格式: postgresql://用户名:密码@主机:端口/数据库名
|
||||||
|
DATABASE_URL="postgresql://eternalai:YOUR_PASSWORD@localhost:5432/eternalai"
|
||||||
|
|
||||||
|
# JWT 密钥 — 生产环境必须修改为随机长字符串(至少 32 字节)
|
||||||
|
# 生成方法: openssl rand -base64 48
|
||||||
|
JWT_SECRET="PLEASE_CHANGE_THIS_TO_A_RANDOM_SECRET"
|
||||||
|
|
||||||
|
# 服务端口
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
# CORS 允许的来源(逗号分隔,生产环境应限制为实际域名)
|
||||||
|
# 留空则允许所有来源(不推荐)
|
||||||
|
ALLOWED_ORIGINS=""
|
||||||
|
|
@ -1,26 +1,72 @@
|
||||||
name: Deploy EternalAI
|
name: Deploy EternalAI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- master
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '20'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
- name: Build project
|
- name: Generate Prisma Client
|
||||||
run: npm run build
|
run: npx prisma generate
|
||||||
|
|
||||||
- name: Deploy
|
- name: Push database schema
|
||||||
run: ./scripts/deploy.sh
|
run: npx prisma db push
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
|
||||||
|
- name: Create logs directory
|
||||||
|
run: mkdir -p logs
|
||||||
|
|
||||||
|
- name: Restart PM2 process
|
||||||
|
run: |
|
||||||
|
if pm2 describe eternalai &>/dev/null; then
|
||||||
|
pm2 reload ecosystem.config.js --update-env
|
||||||
|
echo "PM2 process reloaded"
|
||||||
|
else
|
||||||
|
pm2 start ecosystem.config.js
|
||||||
|
echo "PM2 process started"
|
||||||
|
fi
|
||||||
|
pm2 save
|
||||||
|
|
||||||
|
- name: Health check
|
||||||
|
run: |
|
||||||
|
sleep 3
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3001 || echo "000")
|
||||||
|
if [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
echo "Health check passed (HTTP $HTTP_CODE)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Waiting for server... (attempt $i, HTTP $HTTP_CODE)"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "Health check failed"
|
||||||
|
pm2 logs eternalai --lines 30 --nostream
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Reload Nginx
|
||||||
|
run: sudo nginx -t && sudo nginx -s reload || echo "Nginx reload skipped"
|
||||||
|
|
||||||
|
- name: Deploy summary
|
||||||
|
run: |
|
||||||
|
echo "============================================"
|
||||||
|
echo " Deploy completed: $(git rev-parse --short HEAD)"
|
||||||
|
echo " $(git log -1 --format='%s')"
|
||||||
|
echo "============================================"
|
||||||
|
|
|
||||||
|
|
@ -1 +1,7 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
logs/
|
||||||
|
|
|
||||||
311
README.md
311
README.md
|
|
@ -1,15 +1,306 @@
|
||||||
# EternalAI
|
# Eternal AI
|
||||||
|
|
||||||
一个永恒的 AI 系统,旨在长期运行和自我进化。
|
AI 陪伴平台 — 人设创作者设定并发布角色,自动生成 Hermes Agent 可用的配置文件(SOUL.md + config.yaml),支持跨机器 CLI 拉取部署。
|
||||||
|
|
||||||
## 项目结构
|
## 架构
|
||||||
|
|
||||||
- `src/` - 源代码
|
```
|
||||||
- `docs/` - 文档
|
┌──────────────────────────────────────────────────────────┐
|
||||||
- `tests/` - 测试文件
|
│ 浏览器 (SPA) │
|
||||||
- `scripts/` - 脚本
|
│ index.html + app.js + styles.css │
|
||||||
- `configs/` - 配置文件
|
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌───────────┐ │
|
||||||
|
│ │ 认证视图 │ │ 角色库 │ │ 创作者中心 │ │ 角色编辑器 │ │
|
||||||
|
│ └──────────┘ └──────────┘ └───────────┘ └───────────┘ │
|
||||||
|
└──────────────────────┬───────────────────────────────────┘
|
||||||
|
│ HTTP / JSON
|
||||||
|
┌──────────────────────▼───────────────────────────────────┐
|
||||||
|
│ Express.js 服务端 (server.js) │
|
||||||
|
│ ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌───────────────┐ │
|
||||||
|
│ │ /api/auth│ │/api/roles│ │/api/apikeys│ │ /api/hermes │ │
|
||||||
|
│ └────┬────┘ └────┬────┘ └─────┬────┘ └──────┬────────┘ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ ┌────▼───────────▼────────────▼──────────────▼────────┐ │
|
||||||
|
│ │ 认证中间件 (src/lib/auth.js) │ │
|
||||||
|
│ │ JWT 认证 (authMiddleware) + API Key 认证 │ │
|
||||||
|
│ │ (apiKeyMiddleware, eak_ 前缀, SHA-256 哈希) │ │
|
||||||
|
│ └─────────────────────┬───────────────────────────────┘ │
|
||||||
|
└────────────────────────┼─────────────────────────────────┘
|
||||||
|
│ Prisma ORM
|
||||||
|
┌────────────────────────▼─────────────────────────────────┐
|
||||||
|
│ PostgreSQL 数据库 │
|
||||||
|
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||||||
|
│ │ User │ │ Role │ │ ApiKey │ │ Order │ │
|
||||||
|
│ └────────┘ └────────┘ └────────┘ └────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
## 部署
|
### 技术栈
|
||||||
|
|
||||||
该项目将通过 Gitea Actions 自动部署。
|
| 层 | 技术 |
|
||||||
|
|----|------|
|
||||||
|
| 前端 | 原生 HTML5 SPA(IIFE 模式,无框架)、玻璃拟态 UI |
|
||||||
|
| 后端 | Node.js + Express 5.x |
|
||||||
|
| 数据库 | PostgreSQL 15 + Prisma ORM 5.x |
|
||||||
|
| 认证 | JWT(7 天有效期)+ API Key(`eak_` 前缀,SHA-256 哈希存储) |
|
||||||
|
| 密码 | bcryptjs(10 轮盐) |
|
||||||
|
| 测试 | Playwright E2E(35 个测试用例) |
|
||||||
|
| 部署 | PM2 + Nginx 反向代理 |
|
||||||
|
|
||||||
|
### 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
EternalAI/
|
||||||
|
├── server.js # Express 入口,注册路由 + 静态文件
|
||||||
|
├── app.js # 前端 SPA 逻辑(路由、状态、表单、API 调用)
|
||||||
|
├── index.html # 单页应用入口(9 个视图)
|
||||||
|
├── styles.css # 玻璃拟态样式
|
||||||
|
├── prisma/
|
||||||
|
│ └── schema.prisma # 数据模型定义(User / Role / ApiKey / Order)
|
||||||
|
├── src/
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── auth.js # JWT + API Key 认证中间件
|
||||||
|
│ │ └── prisma.js # Prisma Client 单例
|
||||||
|
│ └── routes/
|
||||||
|
│ ├── auth.js # 注册 / 登录 / 用户信息 / 设置
|
||||||
|
│ ├── roles.js # 角色 CRUD + 角色库
|
||||||
|
│ ├── apikeys.js # API Key 生成 / 列表 / 删除
|
||||||
|
│ └── hermes.js # Hermes 配置拉取端点(SOUL.md / config.yaml)
|
||||||
|
├── deploy/
|
||||||
|
│ ├── setup-server.sh # 服务器一键初始化脚本
|
||||||
|
│ ├── deploy.sh # CI/CD 部署脚本
|
||||||
|
│ └── nginx.conf # Nginx 反向代理配置
|
||||||
|
├── e2e/ # Playwright E2E 测试
|
||||||
|
├── docs/ # 需求文档与规划
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 本地开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 安装依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 2. 配置环境变量
|
||||||
|
cp .env.example .env
|
||||||
|
# 编辑 .env,设置 DATABASE_URL 和 JWT_SECRET
|
||||||
|
# JWT_SECRET 可用 openssl rand -hex 32 生成
|
||||||
|
|
||||||
|
# 3. 初始化数据库
|
||||||
|
npx prisma db push
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
# 4. 启动开发服务器
|
||||||
|
npm run dev
|
||||||
|
# → http://localhost:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
### 服务器部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在服务器上执行一键初始化脚本
|
||||||
|
bash deploy/setup-server.sh
|
||||||
|
# 自动安装 Node.js / PostgreSQL / Nginx / PM2,创建 .env,推送 Schema
|
||||||
|
```
|
||||||
|
|
||||||
|
## 操作流程
|
||||||
|
|
||||||
|
### 1. 用户注册与登录
|
||||||
|
|
||||||
|
```
|
||||||
|
用户 → 首页 → 登录/注册视图
|
||||||
|
├─ 注册:填写账号 + 密码(≥6位)→ 自动登录,返回 JWT
|
||||||
|
└─ 登录:账号 + 密码 → 验证成功,返回 JWT
|
||||||
|
└─ JWT 存入 localStorage,后续请求携带 Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 创作者入驻与角色设定
|
||||||
|
|
||||||
|
```
|
||||||
|
登录用户 → 创作者入驻页 → 开通创作者身份
|
||||||
|
→ 创作者管理中心(三个 Tab)
|
||||||
|
├─ 角色 Tab:新建 / 编辑角色
|
||||||
|
├─ 收入 Tab:余额 + 付费流水
|
||||||
|
└─ 设置 Tab:昵称 / 角色库名 / API Key 管理
|
||||||
|
|
||||||
|
新建角色(4 步表单):
|
||||||
|
Step 1 → 基础信息(名称、性别、年龄、关系)
|
||||||
|
Step 2 → 性格设定(性格、背景、说话风格、喜好)
|
||||||
|
Step 3 → 模型配置(模型、温度、最大 Token、记忆/工具开关)
|
||||||
|
Step 4 → 发布设置(问候语、价格、描述)
|
||||||
|
→ 提交后自动生成 SOUL.md + config.yaml 存入数据库
|
||||||
|
→ 角色上架到角色库
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 角色库浏览与购买
|
||||||
|
|
||||||
|
```
|
||||||
|
任意用户 → 角色库 → 浏览已上架角色
|
||||||
|
→ 点击角色卡片 → 查看详情
|
||||||
|
→ 付款 → 创建订单 → 解锁角色
|
||||||
|
```
|
||||||
|
|
||||||
|
## 与 Hermes Agent 交互流程
|
||||||
|
|
||||||
|
EternalAI 生成的角色配置(SOUL.md + config.yaml)可通过 CLI 拉取命令部署到任意 Hermes Agent 机器上,支持跨机器部署。
|
||||||
|
|
||||||
|
### 架构图
|
||||||
|
|
||||||
|
```
|
||||||
|
服务器 A (EternalAI) 服务器 B (Hermes Agent)
|
||||||
|
┌───────────────────────┐ ┌───────────────────────┐
|
||||||
|
│ 1. 设置页生成 API Key │ │ │
|
||||||
|
│ (eak_xxxxxxxxxx) │ │ 5. hermes profile │
|
||||||
|
│ │ │ create role-mio │
|
||||||
|
│ 2. 创建角色 → 发布 │ │ │
|
||||||
|
│ 生成 SOUL.md + │ │ 6. curl 拉取文件 │
|
||||||
|
│ config.yaml │ │ SOUL.md │
|
||||||
|
│ │ │ config.yaml │
|
||||||
|
│ 3. 角色管理页显示 │ 4. 用户复制 │ │
|
||||||
|
│ curl 命令模板 │──── curl ────▶│ 7. 配置 .env │
|
||||||
|
│ (含 API Key) │ 命令执行 │ (API 密钥) │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ 8. role-mio chat │
|
||||||
|
└───────────────────────┘ └───────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤详解
|
||||||
|
|
||||||
|
#### Step 1-3:在 EternalAI Web UI 操作
|
||||||
|
|
||||||
|
1. **生成 API Key**:创作者管理中心 → 设置 Tab → 点击「生成 API Key」→ 明文 Key 仅显示一次,请妥善保存
|
||||||
|
2. **创建角色**:创作者管理中心 → 角色 Tab → 新建角色 → 填写 4 步表单 → 发布
|
||||||
|
3. **获取部署命令**:角色卡片点击「Hermes 部署」→ 弹窗显示完整的 curl 命令模板(含服务器地址、角色 ID、API Key)→ 点击复制
|
||||||
|
|
||||||
|
#### Step 4-8:在 Hermes Agent 机器上操作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 4. 复制 curl 命令到 Hermes 机器执行
|
||||||
|
|
||||||
|
# 5. 创建 Hermes profile
|
||||||
|
hermes profile create role-mio
|
||||||
|
|
||||||
|
# 6. 拉取 SOUL.md
|
||||||
|
curl -H "Authorization: Bearer eak_xxx" \
|
||||||
|
https://your-eternalai-domain.com/api/hermes/roles/<role-id>/SOUL.md \
|
||||||
|
-o ~/.hermes/profiles/role-mio/SOUL.md
|
||||||
|
|
||||||
|
# 7. 拉取 config.yaml
|
||||||
|
curl -H "Authorization: Bearer eak_xxx" \
|
||||||
|
https://your-eternalai-domain.com/api/hermes/roles/<role-id>/config.yaml \
|
||||||
|
-o ~/.hermes/profiles/role-mio/config.yaml
|
||||||
|
|
||||||
|
# 8. 配置 API 密钥并启动
|
||||||
|
echo "OPENROUTER_API_KEY=sk-or-xxx" > ~/.hermes/profiles/role-mio/.env
|
||||||
|
role-mio chat
|
||||||
|
```
|
||||||
|
|
||||||
|
### config.yaml 格式适配
|
||||||
|
|
||||||
|
EternalAI 角色数据自动转换为 Hermes config.yaml 格式:
|
||||||
|
|
||||||
|
| EternalAI 字段 | Hermes config.yaml 字段 |
|
||||||
|
|---|---|
|
||||||
|
| `role.model` | `model:` |
|
||||||
|
| `role.temperature` | `temperature:` |
|
||||||
|
| `role.maxTokens` | `max_tokens:` |
|
||||||
|
| `role.enableMemory` | `memory.enabled:` |
|
||||||
|
| `role.enableTools` | `tools.enabled:` |
|
||||||
|
|
||||||
|
生成的 config.yaml 示例:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Hermes Agent Config — generated by EternalAI
|
||||||
|
# Character: Mio
|
||||||
|
# Agent ID: default
|
||||||
|
# Generated at: 2026-06-21T12:00:00.000Z
|
||||||
|
|
||||||
|
model: "gpt-4o"
|
||||||
|
temperature: 0.85
|
||||||
|
max_tokens: 2048
|
||||||
|
|
||||||
|
memory:
|
||||||
|
enabled: true
|
||||||
|
storage: local
|
||||||
|
|
||||||
|
tools:
|
||||||
|
enabled: true
|
||||||
|
toolsets:
|
||||||
|
- memory
|
||||||
|
|
||||||
|
terminal:
|
||||||
|
backend: local
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Key 安全机制
|
||||||
|
|
||||||
|
- **格式**:`eak_` + 32 位随机 hex(128 位熵)
|
||||||
|
- **存储**:SHA-256 哈希存储,明文仅在生成时返回一次
|
||||||
|
- **脱敏显示**:列表中仅显示前 12 位(如 `eak_a1b2c3d4`)
|
||||||
|
- **认证**:通过 `Authorization: Bearer eak_xxx` 头传递
|
||||||
|
- **权限**:API Key 只能拉取所属用户创建的角色配置
|
||||||
|
- **可吊销**:随时在设置页删除,删除后立即失效
|
||||||
|
|
||||||
|
## API 参考
|
||||||
|
|
||||||
|
### 认证 API
|
||||||
|
|
||||||
|
| 方法 | 路径 | 认证 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| POST | `/api/auth/register` | 无 | 注册(账号 + 密码) |
|
||||||
|
| POST | `/api/auth/login` | 无 | 登录 |
|
||||||
|
| GET | `/api/auth/me` | JWT | 获取当前用户信息 |
|
||||||
|
| PUT | `/api/auth/settings` | JWT | 更新用户设置 |
|
||||||
|
|
||||||
|
### 角色 API
|
||||||
|
|
||||||
|
| 方法 | 路径 | 认证 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| GET | `/api/roles` | 无 | 获取角色库(已上架) |
|
||||||
|
| GET | `/api/roles/:id` | 无 | 获取角色详情 |
|
||||||
|
| GET | `/api/roles/my/roles` | JWT | 获取我创建的角色 |
|
||||||
|
| POST | `/api/roles` | JWT | 发布新角色 |
|
||||||
|
| PUT | `/api/roles/:id` | JWT | 编辑角色 |
|
||||||
|
| GET | `/api/roles/:id/full` | JWT | 获取角色完整信息(含 SOUL.md) |
|
||||||
|
|
||||||
|
### API Key 管理
|
||||||
|
|
||||||
|
| 方法 | 路径 | 认证 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| POST | `/api/apikeys` | JWT | 生成 API Key(明文仅返回一次) |
|
||||||
|
| GET | `/api/apikeys` | JWT | 列出所有 API Key(脱敏) |
|
||||||
|
| DELETE | `/api/apikeys/:id` | JWT | 删除 API Key |
|
||||||
|
|
||||||
|
### Hermes 配置拉取
|
||||||
|
|
||||||
|
| 方法 | 路径 | 认证 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| GET | `/api/hermes/roles/:id/SOUL.md` | API Key / JWT | 拉取 SOUL.md(text/plain) |
|
||||||
|
| GET | `/api/hermes/roles/:id/config.yaml` | API Key / JWT | 拉取 config.yaml(text/plain) |
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行全部 E2E 测试(35 个用例)
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# 查看测试报告
|
||||||
|
npx playwright show-report
|
||||||
|
```
|
||||||
|
|
||||||
|
测试覆盖:认证流程、角色发布/编辑、角色库浏览、导航与可访问性。
|
||||||
|
|
||||||
|
## 安全
|
||||||
|
|
||||||
|
- **JWT 密钥**:生产环境必须设置 `JWT_SECRET` 环境变量,未设置时 fail-fast 抛错
|
||||||
|
- **密码存储**:bcrypt 哈希(10 轮盐),不存明文
|
||||||
|
- **API Key 存储**:SHA-256 哈希存储,明文仅显示一次
|
||||||
|
- **XSS 防护**:所有用户可控数据通过 `escapeHtml()` 转义后插入 DOM
|
||||||
|
- **YAML 注入防护**:config.yaml 生成时对用户可控字段做双引号转义和换行符过滤
|
||||||
|
- **所有权校验**:所有角色操作验证 `creatorId === req.userId`
|
||||||
|
- **.env 文件权限**:部署脚本自动设置 `chmod 600`
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ===== EternalAI 部署脚本 =====
|
||||||
|
# 每次部署时运行,拉取最新代码、安装依赖、迁移数据库、重启服务
|
||||||
|
# 用法: bash deploy/deploy.sh
|
||||||
|
|
||||||
|
# ---- 颜色输出 ----
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||||
|
|
||||||
|
# ---- 配置 ----
|
||||||
|
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
HEALTH_URL="http://localhost:3001"
|
||||||
|
HEALTH_TIMEOUT=30
|
||||||
|
|
||||||
|
cd "$APP_DIR"
|
||||||
|
info "应用目录: $APP_DIR"
|
||||||
|
|
||||||
|
# ---- 前置检查 ----
|
||||||
|
if [[ ! -f .env ]]; then
|
||||||
|
error ".env 文件不存在,请先运行 deploy/setup-server.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v pm2 &>/dev/null; then
|
||||||
|
error "PM2 未安装,请先运行 deploy/setup-server.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 拉取最新代码 ----
|
||||||
|
info "拉取最新代码..."
|
||||||
|
git fetch --all
|
||||||
|
git reset --hard origin/master
|
||||||
|
info "当前版本: $(git rev-parse --short HEAD)"
|
||||||
|
|
||||||
|
# ---- 安装依赖 ----
|
||||||
|
info "安装 npm 依赖..."
|
||||||
|
npm install --production=false
|
||||||
|
|
||||||
|
# ---- 生成 Prisma Client ----
|
||||||
|
info "生成 Prisma Client..."
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
# ---- 数据库迁移 ----
|
||||||
|
info "推送数据库 Schema..."
|
||||||
|
npx prisma db push
|
||||||
|
|
||||||
|
# ---- 创建日志目录 ----
|
||||||
|
mkdir -p logs
|
||||||
|
|
||||||
|
# ---- 重启 PM2 进程 ----
|
||||||
|
info "重启 PM2 进程..."
|
||||||
|
if pm2 describe eternalai &>/dev/null; then
|
||||||
|
pm2 reload ecosystem.config.js --update-env
|
||||||
|
info "PM2 进程已 reload"
|
||||||
|
else
|
||||||
|
pm2 start ecosystem.config.js
|
||||||
|
info "PM2 进程已启动"
|
||||||
|
fi
|
||||||
|
pm2 save
|
||||||
|
|
||||||
|
# ---- 健康检查 ----
|
||||||
|
info "健康检查 (${HEALTH_TIMEOUT}s 超时)..."
|
||||||
|
ELAPSED=0
|
||||||
|
while [[ $ELAPSED -lt $HEALTH_TIMEOUT ]]; do
|
||||||
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_URL" 2>/dev/null || echo "000")
|
||||||
|
if [[ "$HTTP_CODE" == "200" ]]; then
|
||||||
|
info "健康检查通过 (HTTP $HTTP_CODE)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
ELAPSED=$((ELAPSED + 2))
|
||||||
|
echo -n "."
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ "$HTTP_CODE" != "200" ]]; then
|
||||||
|
error "健康检查失败 (HTTP $HTTP_CODE),请检查日志: pm2 logs eternalai"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 完成 ----
|
||||||
|
echo ""
|
||||||
|
info "============================================"
|
||||||
|
info " 部署完成!"
|
||||||
|
info "============================================"
|
||||||
|
echo ""
|
||||||
|
echo "版本: $(git rev-parse --short HEAD)"
|
||||||
|
echo "分支: $(git branch --show-current)"
|
||||||
|
echo "状态: pm2 status"
|
||||||
|
echo "日志: pm2 logs eternalai"
|
||||||
|
echo ""
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
# ===== EternalAI Nginx 反向代理配置 =====
|
||||||
|
#
|
||||||
|
# 使用方法:
|
||||||
|
# 1. 复制到 Nginx 配置目录:
|
||||||
|
# sudo cp deploy/nginx.conf /etc/nginx/sites-available/eternalai
|
||||||
|
# sudo ln -s /etc/nginx/sites-available/eternalai /etc/nginx/sites-enabled/
|
||||||
|
# 2. 替换 YOUR_DOMAIN 为实际域名
|
||||||
|
# 3. 测试配置: sudo nginx -t
|
||||||
|
# 4. 重载: sudo nginx -s reload
|
||||||
|
#
|
||||||
|
# HTTPS 配置(推荐):
|
||||||
|
# sudo certbot --nginx -d YOUR_DOMAIN
|
||||||
|
|
||||||
|
# 替换 YOUR_DOMAIN 为你的实际域名
|
||||||
|
upstream eternalai_backend {
|
||||||
|
server 127.0.0.1:3001;
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name YOUR_DOMAIN;
|
||||||
|
|
||||||
|
# 安全头
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
|
||||||
|
# 请求体大小限制
|
||||||
|
client_max_body_size 10m;
|
||||||
|
|
||||||
|
# API 请求代理到 Node.js
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://eternalai_backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 静态文件缓存
|
||||||
|
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
proxy_pass http://eternalai_backend;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
expires 1d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# 主页和其他路由
|
||||||
|
location / {
|
||||||
|
proxy_pass http://eternalai_backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 禁止访问敏感文件
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
location ~ /\.(env|git) {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
location ~ /node_modules/ {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
location ~ /prisma/ {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
location ~ /e2e/ {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
location ~ /deploy/ {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
|
||||||
|
access_log /var/log/nginx/eternalai_access.log;
|
||||||
|
error_log /var/log/nginx/eternalai_error.log;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ===== EternalAI 服务器初始化脚本 =====
|
||||||
|
# 首次部署时在服务器上运行,自动检测并安装所有依赖
|
||||||
|
# 用法: bash deploy/setup-server.sh
|
||||||
|
#
|
||||||
|
# 可通过环境变量自定义:
|
||||||
|
# DB_NAME=eternalai 数据库名
|
||||||
|
# DB_USER=eternalai 数据库用户
|
||||||
|
# DB_PASS=xxx 数据库密码(必填,否则自动生成)
|
||||||
|
|
||||||
|
# ---- 颜色输出 ----
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||||
|
|
||||||
|
# ---- 配置 ----
|
||||||
|
DB_NAME="${DB_NAME:-eternalai}"
|
||||||
|
DB_USER="${DB_USER:-eternalai}"
|
||||||
|
DB_PASS="${DB_PASS:-$(openssl rand -base64 24 | tr -d '/+=' | head -c 24)}"
|
||||||
|
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
NODE_VERSION="20"
|
||||||
|
|
||||||
|
info "应用目录: $APP_DIR"
|
||||||
|
info "数据库: $DB_NAME (用户: $DB_USER)"
|
||||||
|
|
||||||
|
# ---- 检测 OS ----
|
||||||
|
if [[ -f /etc/os-release ]]; then
|
||||||
|
. /etc/os-release
|
||||||
|
OS_ID="$ID"
|
||||||
|
info "检测到操作系统: $PRETTY_NAME"
|
||||||
|
else
|
||||||
|
error "无法检测操作系统,仅支持 Ubuntu/Debian/CentOS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 安装 Node.js ----
|
||||||
|
if ! command -v node &>/dev/null; then
|
||||||
|
info "安装 Node.js $NODE_VERSION LTS..."
|
||||||
|
if [[ "$OS_ID" == "ubuntu" || "$OS_ID" == "debian" ]]; then
|
||||||
|
curl -fsSL "https://deb.nodesource.com/setup_${NODE_VERSION}.x" | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
elif [[ "$OS_ID" == "centos" || "$OS_ID" == "rhel" || "$OS_ID" == "rocky" ]]; then
|
||||||
|
curl -fsSL "https://rpm.nodesource.com/setup_${NODE_VERSION}.x" | sudo -E bash -
|
||||||
|
sudo yum install -y nodejs
|
||||||
|
else
|
||||||
|
error "不支持的操作系统: $OS_ID,请手动安装 Node.js $NODE_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
info "Node.js 安装完成: $(node -v)"
|
||||||
|
else
|
||||||
|
info "Node.js 已安装: $(node -v)"
|
||||||
|
NODE_MAJOR=$(node -v | sed 's/v\([0-9]*\).*/\1/')
|
||||||
|
if [[ "$NODE_MAJOR" -lt 18 ]]; then
|
||||||
|
warn "Node.js 版本过低 (当前 $(node -v)),建议升级到 $NODE_VERSION LTS"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 安装 PM2 ----
|
||||||
|
if ! command -v pm2 &>/dev/null; then
|
||||||
|
info "安装 PM2..."
|
||||||
|
sudo npm install -g pm2
|
||||||
|
info "PM2 安装完成: $(pm2 -v)"
|
||||||
|
else
|
||||||
|
info "PM2 已安装: $(pm2 -v)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 安装 PostgreSQL ----
|
||||||
|
if ! command -v psql &>/dev/null; then
|
||||||
|
info "安装 PostgreSQL 15..."
|
||||||
|
if [[ "$OS_ID" == "ubuntu" || "$OS_ID" == "debian" ]]; then
|
||||||
|
sudo apt-get install -y ca-certificates curl gnupg lsb-release
|
||||||
|
sudo install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" | sudo tee /etc/apt/sources.list.d/pgdg.list
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y postgresql-15
|
||||||
|
elif [[ "$OS_ID" == "centos" || "$OS_ID" == "rhel" || "$OS_ID" == "rocky" ]]; then
|
||||||
|
sudo yum install -y "https://download.postgresql.org/pub/repos/yum/reporpms/EL-$(rpm -E %{rhel})-x86_64/pgdg-redhat-repo-latest.noarch.rpm"
|
||||||
|
sudo yum install -y postgresql15-server postgresql15
|
||||||
|
sudo /usr/pgsql-15/bin/postgresql-15-setup initdb
|
||||||
|
else
|
||||||
|
error "不支持的操作系统: $OS_ID,请手动安装 PostgreSQL 15"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
info "PostgreSQL 安装完成"
|
||||||
|
else
|
||||||
|
info "PostgreSQL 已安装: $(psql --version)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 启动 PostgreSQL ----
|
||||||
|
if ! sudo systemctl is-active --quiet postgresql; then
|
||||||
|
info "启动 PostgreSQL 服务..."
|
||||||
|
sudo systemctl enable postgresql
|
||||||
|
sudo systemctl start postgresql
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 创建数据库和用户 ----
|
||||||
|
info "创建数据库 $DB_NAME 和用户 $DB_USER..."
|
||||||
|
sudo -u postgres psql -tc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'" | grep -q 1 || {
|
||||||
|
sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';"
|
||||||
|
info "数据库用户 $DB_USER 已创建"
|
||||||
|
}
|
||||||
|
sudo -u postgres psql -tc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1 || {
|
||||||
|
sudo -u postgres psql -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;"
|
||||||
|
info "数据库 $DB_NAME 已创建"
|
||||||
|
}
|
||||||
|
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;" >/dev/null
|
||||||
|
|
||||||
|
# 确保 PostgreSQL 对 pg_hba.conf 允许密码认证
|
||||||
|
PG_HBA=$(sudo -u postgres psql -t -c "SHOW hba_file" | xargs)
|
||||||
|
if [[ -f "$PG_HBA" ]]; then
|
||||||
|
if ! sudo grep -q "$DB_USER" "$PG_HBA" 2>/dev/null; then
|
||||||
|
warn "如果连接数据库失败,请检查 $PG_HBA 是否允许 md5/scram-sha-256 认证"
|
||||||
|
warn "添加: host $DB_NAME $DB_USER 127.0.0.1/32 scram-sha-256"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 安装 Nginx ----
|
||||||
|
if ! command -v nginx &>/dev/null; then
|
||||||
|
info "安装 Nginx..."
|
||||||
|
if [[ "$OS_ID" == "ubuntu" || "$OS_ID" == "debian" ]]; then
|
||||||
|
sudo apt-get install -y nginx
|
||||||
|
elif [[ "$OS_ID" == "centos" || "$OS_ID" == "rhel" || "$OS_ID" == "rocky" ]]; then
|
||||||
|
sudo yum install -y nginx
|
||||||
|
fi
|
||||||
|
info "Nginx 安装完成: $(nginx -v 2>&1)"
|
||||||
|
else
|
||||||
|
info "Nginx 已安装: $(nginx -v 2>&1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! sudo systemctl is-active --quiet nginx; then
|
||||||
|
sudo systemctl enable nginx
|
||||||
|
sudo systemctl start nginx
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 创建 .env 文件 ----
|
||||||
|
ENV_FILE="$APP_DIR/.env"
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
info "创建 .env 文件..."
|
||||||
|
JWT_SECRET=$(openssl rand -base64 48)
|
||||||
|
cat > "$ENV_FILE" <<EOF
|
||||||
|
DATABASE_URL="postgresql://$DB_USER:$DB_PASS@localhost:5432/$DB_NAME"
|
||||||
|
JWT_SECRET="$JWT_SECRET"
|
||||||
|
PORT=3001
|
||||||
|
EOF
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
info ".env 文件已创建(权限 600,请妥善保管数据库密码和 JWT 密钥)"
|
||||||
|
echo ""
|
||||||
|
echo "=============================="
|
||||||
|
echo " JWT 密钥已自动生成"
|
||||||
|
echo " 数据库密码已写入 .env 文件"
|
||||||
|
echo "=============================="
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
info ".env 文件已存在,跳过创建"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 创建日志目录 ----
|
||||||
|
mkdir -p "$APP_DIR/logs"
|
||||||
|
|
||||||
|
# ---- 安装项目依赖 ----
|
||||||
|
info "安装项目依赖..."
|
||||||
|
cd "$APP_DIR"
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# ---- 生成 Prisma Client ----
|
||||||
|
info "生成 Prisma Client..."
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
# ---- 推送数据库 Schema ----
|
||||||
|
info "推送数据库 Schema..."
|
||||||
|
npx prisma db push
|
||||||
|
|
||||||
|
# ---- 配置 PM2 开机自启 ----
|
||||||
|
info "配置 PM2 开机自启..."
|
||||||
|
pm2 start ecosystem.config.js || true
|
||||||
|
pm2 save
|
||||||
|
pm2 startup systemd -u "$(whoami)" --hp "$HOME" 2>/dev/null | grep "sudo" | bash || true
|
||||||
|
|
||||||
|
# ---- 完成 ----
|
||||||
|
echo ""
|
||||||
|
info "============================================"
|
||||||
|
info " 服务器初始化完成!"
|
||||||
|
info "============================================"
|
||||||
|
echo ""
|
||||||
|
echo "下一步:"
|
||||||
|
echo " 1. 配置 Nginx 反向代理(参考 deploy/nginx.conf)"
|
||||||
|
echo " 2. 检查应用状态: pm2 status"
|
||||||
|
echo " 3. 查看日志: pm2 logs eternalai"
|
||||||
|
echo " 4. 访问应用: http://localhost:3001"
|
||||||
|
echo ""
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
# Hermes Agent 跨机器部署 — 需求文档
|
||||||
|
|
||||||
|
**日期**: 2026-06-20
|
||||||
|
**状态**: 已确认,待实现
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
EternalAI 生成的角色配置(Soul.md + config.yaml)需要能直接复制到远程 Hermes Agent 中使用。EternalAI 服务器与 Hermes Agent 可能不在同一台机器上,用户通过 CLI 拉取命令(curl)跨机器获取配置文件。
|
||||||
|
|
||||||
|
## 用户场景
|
||||||
|
|
||||||
|
用户在 EternalAI Web UI 创建角色后,需要在另一台安装了 Hermes Agent 的机器上部署该角色。用户不希望 EternalAI 直接写入 Hermes 目录,而是生成可直接使用的文件,通过 curl 命令拉取后手动放置到 Hermes profile 目录。
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
```
|
||||||
|
服务器 A (EternalAI) 服务器 B (Hermes Agent)
|
||||||
|
┌─────────────────────┐ ┌─────────────────────┐
|
||||||
|
│ 1. 设置页生成 API Key│ │ │
|
||||||
|
│ (eak_xxxxx) │ │ 5. hermes profile │
|
||||||
|
│ │ │ create role-mio │
|
||||||
|
│ 2. 创建角色 → 发布 │ │ │
|
||||||
|
│ 生成 Soul.md + │ │ 6. curl 拉取文件 │
|
||||||
|
│ config.yaml │ │ SOUL.md │
|
||||||
|
│ │ │ config.yaml │
|
||||||
|
│ 3. 角色管理页显示 │ 4. 用户复制 │ │
|
||||||
|
│ curl 命令模板 │──── curl ────▶│ 7. 配置 .env │
|
||||||
|
│ (含 API Key) │ 命令执行 │ (API 密钥) │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ 8. role-mio chat │
|
||||||
|
└─────────────────────┘ └─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 用户在 Hermes 机器上的操作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 创建 profile
|
||||||
|
hermes profile create role-mio
|
||||||
|
|
||||||
|
# 2. 拉取 SOUL.md
|
||||||
|
curl -H "Authorization: Bearer eak_xxx" \
|
||||||
|
https://eternalai.example.com/api/hermes/roles/<id>/SOUL.md \
|
||||||
|
-o ~/.hermes/profiles/role-mio/SOUL.md
|
||||||
|
|
||||||
|
# 3. 拉取 config.yaml
|
||||||
|
curl -H "Authorization: Bearer eak_xxx" \
|
||||||
|
https://eternalai.example.com/api/hermes/roles/<id>/config.yaml \
|
||||||
|
-o ~/.hermes/profiles/role-mio/config.yaml
|
||||||
|
|
||||||
|
# 4. 配置 API 密钥
|
||||||
|
echo "OPENROUTER_API_KEY=sk-or-xxx" > ~/.hermes/profiles/role-mio/.env
|
||||||
|
|
||||||
|
# 5. 启动
|
||||||
|
role-mio chat
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能需求
|
||||||
|
|
||||||
|
### 1. API Key 管理
|
||||||
|
|
||||||
|
- 用户在设置页生成长期 API Key(格式:`eak_` + 32 位随机 hex)
|
||||||
|
- API Key 列表显示:名称、创建时间、最后使用时间、Key(脱敏显示)
|
||||||
|
- 支持删除 API Key
|
||||||
|
- API Key 存储在数据库中,使用 bcrypt 哈希存储(只明文显示一次)
|
||||||
|
|
||||||
|
### 2. Hermes 配置 API
|
||||||
|
|
||||||
|
两个端点,均需 API Key 认证:
|
||||||
|
|
||||||
|
- `GET /api/hermes/roles/:id/SOUL.md` — 返回角色的 SOUL.md 内容(text/plain)
|
||||||
|
- `GET /api/hermes/roles/:id/config.yaml` — 返回适配后的 Hermes config.yaml(text/plain)
|
||||||
|
|
||||||
|
认证方式:`Authorization: Bearer eak_xxxxx`
|
||||||
|
|
||||||
|
权限:API Key 所属用户只能拉取自己创建的角色。
|
||||||
|
|
||||||
|
### 3. config.yaml 格式适配
|
||||||
|
|
||||||
|
EternalAI 数据库中的角色数据 → Hermes config.yaml 格式:
|
||||||
|
|
||||||
|
| EternalAI 字段 | Hermes config.yaml 字段 |
|
||||||
|
|---|---|
|
||||||
|
| `role.model` | `model:` |
|
||||||
|
| `role.temperature` | `temperature:` |
|
||||||
|
| `role.maxTokens` | `max_tokens:` |
|
||||||
|
| `role.enableMemory` | `memory.enabled:` |
|
||||||
|
| `role.enableTools` | `tools.enabled:` |
|
||||||
|
|
||||||
|
### 4. Web UI 部署指南
|
||||||
|
|
||||||
|
角色管理页每个角色卡片增加"Hermes 部署"区域:
|
||||||
|
- 显示完整的 curl 命令模板(自动填入服务器地址、角色 ID、API Key)
|
||||||
|
- 操作步骤说明
|
||||||
|
- 复制按钮
|
||||||
|
|
||||||
|
## 非功能需求
|
||||||
|
|
||||||
|
- API Key 使用 bcrypt 哈希存储,明文只在生成时显示一次
|
||||||
|
- Hermes API 端点返回 `text/plain` 格式,适合 curl 直接输出到文件
|
||||||
|
- config.yaml 适配在后端完成,用户拉取到的文件可直接使用
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,332 @@
|
||||||
|
# EternalAI 部署指南
|
||||||
|
|
||||||
|
## 架构概览
|
||||||
|
|
||||||
|
```
|
||||||
|
用户浏览器 → Nginx (80/443) → Node.js Express (3001) → PostgreSQL (5432)
|
||||||
|
↓
|
||||||
|
PM2 进程管理
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文件清单
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `ecosystem.config.js` | PM2 进程配置 |
|
||||||
|
| `.env.example` | 环境变量模板 |
|
||||||
|
| `deploy/setup-server.sh` | 首次服务器初始化脚本 |
|
||||||
|
| `deploy/deploy.sh` | 每次部署脚本 |
|
||||||
|
| `deploy/nginx.conf` | Nginx 反向代理配置模板 |
|
||||||
|
| `.gitea/workflows/deploy.yml` | Gitea Actions CI/CD 工作流 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、首次部署(全量部署)
|
||||||
|
|
||||||
|
### 1.1 准备服务器
|
||||||
|
|
||||||
|
在目标服务器上执行以下操作。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆代码仓库
|
||||||
|
cd /opt # 或你选择的部署目录
|
||||||
|
git clone http://gitea.fischerai.cn/chigulong/eternalai.git
|
||||||
|
cd eternalai
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 运行服务器初始化脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/setup-server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
此脚本会自动完成:
|
||||||
|
- 检测并安装 Node.js 20 LTS(如缺失)
|
||||||
|
- 检测并安装 PostgreSQL 15(如缺失)
|
||||||
|
- 检测并安装 PM2(如缺失)
|
||||||
|
- 检测并安装 Nginx(如缺失)
|
||||||
|
- 创建数据库 `eternalai` 和用户
|
||||||
|
- 自动生成 `.env` 文件(含随机 JWT 密钥和数据库密码)
|
||||||
|
- 安装 npm 依赖
|
||||||
|
- 生成 Prisma Client
|
||||||
|
- 推送数据库 Schema
|
||||||
|
- 启动 PM2 进程并配置开机自启
|
||||||
|
|
||||||
|
**自定义数据库配置**(可选):
|
||||||
|
```bash
|
||||||
|
DB_NAME=mydb DB_USER=myuser DB_PASS=mypassword bash deploy/setup-server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本完成后会输出数据库密码,请妥善保存。
|
||||||
|
|
||||||
|
### 1.3 配置 Nginx 反向代理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 复制 Nginx 配置
|
||||||
|
sudo cp deploy/nginx.conf /etc/nginx/sites-available/eternalai
|
||||||
|
sudo ln -s /etc/nginx/sites-available/eternalai /etc/nginx/sites-enabled/
|
||||||
|
|
||||||
|
# 编辑配置,替换 YOUR_DOMAIN 为实际域名
|
||||||
|
sudo nano /etc/nginx/sites-enabled/eternalai
|
||||||
|
# 将所有 YOUR_DOMAIN 替换为你的域名,如 eternalai.example.com
|
||||||
|
|
||||||
|
# 测试配置
|
||||||
|
sudo nginx -t
|
||||||
|
|
||||||
|
# 重载 Nginx
|
||||||
|
sudo nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 配置 HTTPS(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装 Certbot
|
||||||
|
sudo apt-get install -y certbot python3-certbot-nginx # Ubuntu/Debian
|
||||||
|
# 或
|
||||||
|
sudo yum install -y certbot python3-certbot-nginx # CentOS/RHEL
|
||||||
|
|
||||||
|
# 自动获取并配置 SSL 证书
|
||||||
|
sudo certbot --nginx -d YOUR_DOMAIN
|
||||||
|
|
||||||
|
# 证书自动续期(Certbot 会自动配置 cron)
|
||||||
|
sudo certbot renew --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.5 验证部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查 PM2 进程状态
|
||||||
|
pm2 status
|
||||||
|
|
||||||
|
# 查看应用日志
|
||||||
|
pm2 logs eternalai
|
||||||
|
|
||||||
|
# 测试本地访问
|
||||||
|
curl http://localhost:3001
|
||||||
|
|
||||||
|
# 测试 Nginx 代理访问
|
||||||
|
curl http://YOUR_DOMAIN
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、CI/CD 自动部署(推送即部署)
|
||||||
|
|
||||||
|
### 2.1 配置 Gitea Secrets
|
||||||
|
|
||||||
|
在 Gitea 仓库设置中添加 Secret:
|
||||||
|
|
||||||
|
1. 进入仓库 → Settings → Actions → Secrets
|
||||||
|
2. 添加 `DATABASE_URL`,值为 `.env` 文件中的 `DATABASE_URL`
|
||||||
|
|
||||||
|
### 2.2 配置 Gitea Runner
|
||||||
|
|
||||||
|
确保已注册 self-hosted runner:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在服务器上注册 runner(如尚未注册)
|
||||||
|
# 参考 Gitea 官方文档: https://docs.gitea.com/usage/actions/quickstart
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 自动部署流程
|
||||||
|
|
||||||
|
每次推送代码到 `master` 分支时,Gitea Actions 会自动:
|
||||||
|
|
||||||
|
1. 拉取最新代码
|
||||||
|
2. 安装 npm 依赖
|
||||||
|
3. 生成 Prisma Client
|
||||||
|
4. 推送数据库 Schema(`prisma db push`)
|
||||||
|
5. 重启 PM2 进程(`pm2 reload`)
|
||||||
|
6. 健康检查(等待 HTTP 200)
|
||||||
|
7. 重载 Nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 推送代码触发自动部署
|
||||||
|
git push origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 手动触发部署
|
||||||
|
|
||||||
|
在 Gitea 仓库 → Actions 页面,可手动触发 `Deploy EternalAI` 工作流。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、手动部署(不使用 CI/CD)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH 登录服务器
|
||||||
|
ssh user@your-server
|
||||||
|
|
||||||
|
# 进入应用目录
|
||||||
|
cd /opt/eternalai
|
||||||
|
|
||||||
|
# 运行部署脚本
|
||||||
|
bash deploy/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
部署脚本会自动完成:拉取代码 → 安装依赖 → 数据库迁移 → 重启 PM2 → 健康检查。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、常用运维命令
|
||||||
|
|
||||||
|
### PM2 进程管理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 status # 查看进程状态
|
||||||
|
pm2 logs eternalai # 实时查看日志
|
||||||
|
pm2 logs eternalai --lines 100 # 查看最近 100 行日志
|
||||||
|
pm2 restart eternalai # 重启进程
|
||||||
|
pm2 reload eternalai # 零停机重载
|
||||||
|
pm2 stop eternalai # 停止进程
|
||||||
|
pm2 delete eternalai # 删除进程
|
||||||
|
pm2 monit # 监控面板
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据库操作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 推送 Schema 变更
|
||||||
|
npx prisma db push
|
||||||
|
|
||||||
|
# 打开 Prisma Studio(数据库可视化管理)
|
||||||
|
npx prisma studio
|
||||||
|
|
||||||
|
# 连接 PostgreSQL
|
||||||
|
psql -U eternalai -d eternalai -h localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx 操作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nginx -t # 测试配置
|
||||||
|
sudo nginx -s reload # 重载配置
|
||||||
|
sudo systemctl status nginx # 查看状态
|
||||||
|
sudo tail -f /var/log/nginx/eternalai_error.log # 查看错误日志
|
||||||
|
```
|
||||||
|
|
||||||
|
### 日志查看
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 应用日志
|
||||||
|
tail -f logs/out.log # 标准输出
|
||||||
|
tail -f logs/err.log # 错误输出
|
||||||
|
|
||||||
|
# Nginx 日志
|
||||||
|
sudo tail -f /var/log/nginx/eternalai_access.log
|
||||||
|
sudo tail -f /var/log/nginx/eternalai_error.log
|
||||||
|
|
||||||
|
# PostgreSQL 日志
|
||||||
|
sudo tail -f /var/log/postgresql/postgresql-15-main.log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、更新部署
|
||||||
|
|
||||||
|
### 5.1 日常更新(代码变更)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 方法一:推送代码触发 CI/CD(推荐)
|
||||||
|
git push origin master
|
||||||
|
|
||||||
|
# 方法二:手动部署
|
||||||
|
bash deploy/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 数据库 Schema 变更
|
||||||
|
|
||||||
|
修改 `prisma/schema.prisma` 后:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 推送到 master,CI/CD 会自动执行 prisma db push
|
||||||
|
git add prisma/schema.prisma
|
||||||
|
git commit -m "feat: update schema"
|
||||||
|
git push origin master
|
||||||
|
|
||||||
|
# 或手动执行
|
||||||
|
npx prisma db push
|
||||||
|
pm2 reload eternalai
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 环境变量变更
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 编辑 .env 文件
|
||||||
|
nano .env
|
||||||
|
|
||||||
|
# 重启应用使配置生效
|
||||||
|
pm2 reload eternalai --update-env
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、回滚
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看部署历史
|
||||||
|
git log --oneline -10
|
||||||
|
|
||||||
|
# 回滚到指定版本
|
||||||
|
git checkout <commit-hash>
|
||||||
|
npm install
|
||||||
|
npx prisma db push
|
||||||
|
pm2 reload eternalai
|
||||||
|
|
||||||
|
# 或回滚到上一个版本
|
||||||
|
git checkout HEAD~1
|
||||||
|
npm install
|
||||||
|
npx prisma db push
|
||||||
|
pm2 reload eternalai
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、故障排查
|
||||||
|
|
||||||
|
### 应用无法启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看错误日志
|
||||||
|
pm2 logs eternalai --err --lines 50
|
||||||
|
|
||||||
|
# 常见原因:
|
||||||
|
# 1. .env 文件缺失 → 运行 deploy/setup-server.sh
|
||||||
|
# 2. 数据库连接失败 → 检查 DATABASE_URL 和 PostgreSQL 服务
|
||||||
|
# 3. 端口被占用 → 检查 PORT 环境变量
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据库连接失败
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查 PostgreSQL 服务
|
||||||
|
sudo systemctl status postgresql
|
||||||
|
|
||||||
|
# 测试连接
|
||||||
|
psql -U eternalai -d eternalai -h localhost -W
|
||||||
|
|
||||||
|
# 检查 pg_hba.conf 认证配置
|
||||||
|
sudo cat /etc/postgresql/15/main/pg_hba.conf | grep -v '^#'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx 502 Bad Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查 Node.js 进程是否运行
|
||||||
|
pm2 status
|
||||||
|
|
||||||
|
# 检查端口
|
||||||
|
curl http://localhost:3001
|
||||||
|
|
||||||
|
# 检查 Nginx 错误日志
|
||||||
|
sudo tail -f /var/log/nginx/eternalai_error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### PM2 进程未开机自启
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 重新配置开机自启
|
||||||
|
pm2 startup systemd
|
||||||
|
# 执行输出的 sudo 命令
|
||||||
|
pm2 save
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,375 @@
|
||||||
|
# Eternal AI 完整功能与链接路由规划
|
||||||
|
|
||||||
|
**Type:** feat
|
||||||
|
**Date:** 2026-06-20
|
||||||
|
**Origin:** `Eternal_AI_PRD_v1.docx`(微信小程序 PRD v1.0)
|
||||||
|
**Target repo:** EternalAI
|
||||||
|
**Status:** active
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Summary
|
||||||
|
|
||||||
|
本规划依据 `Eternal_AI_PRD_v1.docx` 对现有 HTML5 落地页进行补全,核心目标是:
|
||||||
|
|
||||||
|
1. 让首页四个入口(我的 XXX、蒸馏前任、关于 Eternal AI、申请入驻)分别指向 PRD 定义的独立页面,而不是全部打开同一个人设蒸馏表单。
|
||||||
|
2. 补齐 PRD 中缺失的 4 个页面:角色库页(P2)、角色详情页(P3)、关于 Eternal AI 页(P5)、创作者入驻页(P6)。
|
||||||
|
3. 将现有的人设蒸馏表单重新定位到「创作者管理中心 - 角色编辑」(P7),保留 Soul.md / config.yaml 生成功能。
|
||||||
|
4. 增加底部 tabBar(首页 / 蒸馏前任 / 我的专属创作者),与 PRD 导航结构一致。
|
||||||
|
|
||||||
|
当前已实现的页面:首页(P1)、登录/注册视图、蒸馏前任服务页(P4)、人设蒸馏表单(待重新定位)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem Frame
|
||||||
|
|
||||||
|
现有实现把三个完全不同的用户意图塞进了同一个表单:
|
||||||
|
|
||||||
|
- **普通用户想找角色库** → 打开了登录/注册(已修正,但登录后无角色库可去)。
|
||||||
|
- **普通用户想购买蒸馏前任服务** → 打开了正确页面(P4)。
|
||||||
|
- **创作者想入驻** → 打开了人设蒸馏表单(错误:PRD 要求 P6 仅展示合作说明与微信二维码,不填表单)。
|
||||||
|
- **关于 Eternal AI** → 仅有一个空锚点 `#about`,无实际页面。
|
||||||
|
|
||||||
|
此外,PRD 定义的角色库(P2)、角色详情(P3)、创作者管理中心(P7)均未实现,导致用户登录后没有内容,创作者也没有入口管理角色和收入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Requirements Traceability
|
||||||
|
|
||||||
|
| PRD 页面 | 访问权限 | 入口 | 当前状态 | 规划动作 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| P1 首页 | 所有人 | 打开站点 | 已实现 | 保持,调整链接 |
|
||||||
|
| P2 角色库页 | 已登录 + 已绑定创作者 | 首页「我的 XXX」 | 缺失 | 新增 |
|
||||||
|
| P3 角色详情页 | 已登录用户 | P2 角色卡片 | 缺失 | 新增 |
|
||||||
|
| P4 蒸馏前任页 | 所有人 | 首页「蒸馏前任」/ tabBar | 已实现 | 保持,补充底部 tabBar 入口 |
|
||||||
|
| P5 关于 Eternal AI 页 | 所有人 | 首页底部「关于 Eternal AI」 | 缺失 | 新增 |
|
||||||
|
| P6 创作者入驻页 | 所有人 | 首页底部「申请入驻」 | 错误指向 creator 表单 | 改为微信联系说明页 |
|
||||||
|
| P7 创作者管理中心 | 创作者账号登录后 | tabBar「管理」 | 缺失 | 新增,复用现有 creator 表单作为「角色编辑」 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Link Rationality(首页入口调整说明)
|
||||||
|
|
||||||
|
### 4.1 我的 XXX → 登录 → 角色库(P2)
|
||||||
|
|
||||||
|
**为什么这样设计:**
|
||||||
|
- PRD 中「我的 XXX」是角色库入口,而角色库需要登录后才能访问。
|
||||||
|
- 未登录用户点击后应先进入登录/注册;登录后根据是否绑定创作者决定展示内容。
|
||||||
|
- 登录态由前端本地模拟(localStorage 标记),后续由后端接管。
|
||||||
|
|
||||||
|
**状态分支:**
|
||||||
|
- 未登录 → 登录/注册视图。
|
||||||
|
- 已登录 + 已绑定创作者 → P2 角色库(显示该创作者上架的角色)。
|
||||||
|
- 已登录 + 未绑定创作者 → P2 角色库占位态,提示「你还没有绑定专属创作者」。
|
||||||
|
|
||||||
|
### 4.2 蒸馏前任 → P4 蒸馏前任服务页
|
||||||
|
|
||||||
|
**为什么这样设计:**
|
||||||
|
- 与 PRD 一致,面向情感类用户的自营服务。
|
||||||
|
- 保持现有 P4 页面结构:服务介绍、流程、价格、下单、客服微信、分润说明。
|
||||||
|
|
||||||
|
### 4.3 关于 Eternal AI → P5 关于页
|
||||||
|
|
||||||
|
**为什么这样设计:**
|
||||||
|
- PRD 明确这是建立女性用户信任的页面,需要展示平台简介、连接方式说明、FAQ(安全/隐私/账号/情感)。
|
||||||
|
- 当前空锚点 `#about` 无法承载任何信息,必须新建独立视图。
|
||||||
|
|
||||||
|
### 4.4 我是创作者,申请入驻 → P6 创作者入驻页
|
||||||
|
|
||||||
|
**为什么这样设计:**
|
||||||
|
- PRD 明确 P6「不做表单提交,直接走微信私聊沟通」。
|
||||||
|
- 现有的人设蒸馏表单是创作者入驻后的生产工具,不是入驻申请本身。
|
||||||
|
- 因此 footer 入口应改为展示合作模式、分润比例、负责人微信二维码的引导页。
|
||||||
|
|
||||||
|
### 4.5 底部 tabBar
|
||||||
|
|
||||||
|
**为什么这样设计:**
|
||||||
|
- PRD 底部导航为「首页 / 蒸馏前任 / 我的专属创作者」。
|
||||||
|
- 在 HTML5 站点中用固定底部 tabBar 模拟小程序导航,让 P4 和 P2/P7 都有全局入口。
|
||||||
|
- tabBar 在首页、蒸馏前任、角色库/创作者中心之间切换。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Implementation Units
|
||||||
|
|
||||||
|
### U1. 调整「我的 XXX」链路:未登录进登录页,已登录进角色库
|
||||||
|
|
||||||
|
**Goal:** 让首页「我的 XXX」卡片根据登录态正确分流。
|
||||||
|
|
||||||
|
**Requirements:** PRD P1 板块三、P2 条件逻辑。
|
||||||
|
|
||||||
|
**Dependencies:** U2(角色库页必须先存在)。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `index.html`
|
||||||
|
- `app.js`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- 在 `app.js` 增加 `authState` 对象:`{ isLoggedIn: bool, boundCreator: object|null }`。
|
||||||
|
- 点击「我的 XXX」时:
|
||||||
|
- 未登录 → `showView('auth')`,登录成功后写入 `authState` 并跳转 P2。
|
||||||
|
- 已登录 → 直接 `showView('role-library')`。
|
||||||
|
- 登录/注册提交不再 `alert`,而是更新状态并跳转。
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 未登录点击「我的 XXX」→ 显示登录页。
|
||||||
|
- 登录成功后点击「我的 XXX」→ 显示角色库。
|
||||||
|
- 登录后 `boundCreator` 为空 → 角色库显示「未绑定创作者」提示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U2. 新增角色库页(P2)
|
||||||
|
|
||||||
|
**Goal:** 展示创作者上架的所有 AI 角色。
|
||||||
|
|
||||||
|
**Requirements:** PRD P2。
|
||||||
|
|
||||||
|
**Dependencies:** U1。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `index.html`(新增 `#role-library` view)
|
||||||
|
- `styles.css`
|
||||||
|
- `app.js`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- 新增 `view--role-library`,顶部显示创作者自定义名称(如「云朵的后宫」)。
|
||||||
|
- 角色卡片列表:头像、名字、简短简介、价格。
|
||||||
|
- 点击卡片 → P3 角色详情。
|
||||||
|
- 空态:未绑定创作者时显示提示文案。
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 角色列表按 PRD 字段渲染。
|
||||||
|
- 点击卡片正确跳转角色详情。
|
||||||
|
- 未绑定创作者时显示空态提示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U3. 新增角色详情页(P3)
|
||||||
|
|
||||||
|
**Goal:** 展示单个角色详情并模拟订阅/二维码流程。
|
||||||
|
|
||||||
|
**Requirements:** PRD P3。
|
||||||
|
|
||||||
|
**Dependencies:** U2。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `index.html`(新增 `#role-detail` view)
|
||||||
|
- `styles.css`
|
||||||
|
- `app.js`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- 新增 `view--role-detail`,包含:角色大图、名字、详细介绍、价格、付款按钮。
|
||||||
|
- 点击「付款」后切换到已付款态:显示微信专属二维码、可下载头像、引导文案。
|
||||||
|
- 由于无真实支付接口,付款按钮先切换本地状态并展示二维码占位图。
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 付款前态显示价格与按钮。
|
||||||
|
- 点击付款后切换到已付款态,显示二维码和头像下载。
|
||||||
|
- 返回按钮回到角色库。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U4. 新增关于 Eternal AI 页(P5)
|
||||||
|
|
||||||
|
**Goal:** 建立用户信任,解答安全、隐私、账号、情感类常见问题。
|
||||||
|
|
||||||
|
**Requirements:** PRD P5。
|
||||||
|
|
||||||
|
**Dependencies:** 无。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `index.html`(新增 `#about` view)
|
||||||
|
- `styles.css`
|
||||||
|
- `app.js`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- 新增 `view--about`,结构:平台简介、连接方式说明(Claw / iLink)、FAQ 折叠面板。
|
||||||
|
- FAQ 分类:安全类、隐私类、账号类、情感类。
|
||||||
|
- 首页底部「关于 Eternal AI」改为 `data-action="open-about"`。
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 点击 footer「关于 Eternal AI」打开 P5。
|
||||||
|
- FAQ 可展开/收起。
|
||||||
|
- 返回按钮回到首页。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U5. 重构创作者入驻页(P6)为微信联系引导
|
||||||
|
|
||||||
|
**Goal:** 将 footer「申请入驻」从 creator 表单改为 PRD 定义的入驻说明页。
|
||||||
|
|
||||||
|
**Requirements:** PRD P6。
|
||||||
|
|
||||||
|
**Dependencies:** 无。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `index.html`(新增 `#creator-onboarding` view,或复用/替换原 footer 目标)
|
||||||
|
- `styles.css`
|
||||||
|
- `app.js`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- 新增 `view--creator-onboarding`:入驻说明、合作模式(20% 平台 + 80% 创作者)、负责人微信二维码/微信号。
|
||||||
|
- 不提交表单,仅展示联系方式。
|
||||||
|
- footer「我是创作者,申请入驻」改为 `data-action="open-creator-onboarding"`。
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 点击 footer「申请入驻」打开 P6 说明页。
|
||||||
|
- 页面包含微信二维码和分润说明。
|
||||||
|
- 返回按钮回到首页。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U6. 将现有人设蒸馏表单重新定位为「创作者管理中心 - 角色编辑」
|
||||||
|
|
||||||
|
**Goal:** 保留 Soul.md / config.yaml 生成功能,但仅作为创作者后台工具。
|
||||||
|
|
||||||
|
**Requirements:** PRD P7 Tab 1 角色管理页;同时满足用户原始需求(生成 Hermes agent 配置文件)。
|
||||||
|
|
||||||
|
**Dependencies:** U5。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `index.html`
|
||||||
|
- `styles.css`
|
||||||
|
- `app.js`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- 当前 `#creator` view 从「landing footer 入口」改为「creator center 内部编辑页」。
|
||||||
|
- 入口文案调整:header 从「Eternal AI / 人设蒸馏器」改为「角色编辑 / 生成配置文件」。
|
||||||
|
- 保留 4 步表单和下载逻辑。
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 从创作者管理中心点击角色进入编辑页时,表单正常显示。
|
||||||
|
- 提交后仍能生成并下载 Soul.md + config.yaml。
|
||||||
|
- 返回按钮回到创作者管理中心的角色列表。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U7. 新增创作者管理中心(P7)
|
||||||
|
|
||||||
|
**Goal:** 为创作者提供角色管理、收入、账户设置入口。
|
||||||
|
|
||||||
|
**Requirements:** PRD P7。
|
||||||
|
|
||||||
|
**Dependencies:** U5, U6。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `index.html`(新增 `#creator-center` view)
|
||||||
|
- `styles.css`
|
||||||
|
- `app.js`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- 新增 `view--creator-center`,内部三个 tab:我的角色 / 收入 / 我的。
|
||||||
|
- **我的角色:** 角色列表(头像、名字、状态),点击角色进入 U6 的编辑页,新增角色也进入同一表单。
|
||||||
|
- **收入:** 可提现余额、流水明细、提现申请表单(收款方式 + 金额)。
|
||||||
|
- **我的:** 创作者名字、自定义角色库名称(即首页「我的 XXX」显示文字)、退出登录。
|
||||||
|
- 该中心仅在 `authState` 标记为创作者时从 tabBar「管理」进入;普通用户该 tab 指向角色库或提示申请创作者。
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 三个 tab 可切换。
|
||||||
|
- 点击角色进入编辑表单。
|
||||||
|
- 自定义角色库名称保存后,首页「我的 XXX」同步更新。
|
||||||
|
- 收入页显示余额与流水,提现表单可提交(前端模拟)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U8. 新增底部 tabBar 导航
|
||||||
|
|
||||||
|
**Goal:** 模拟 PRD 的小程序底部导航:首页 / 蒸馏前任 / 我的专属创作者。
|
||||||
|
|
||||||
|
**Requirements:** PRD P1 底部导航栏。
|
||||||
|
|
||||||
|
**Dependencies:** U2, U4, U7。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `index.html`
|
||||||
|
- `styles.css`
|
||||||
|
- `app.js`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- 在 `index.html` 底部新增 `.tab-bar` 固定元素。
|
||||||
|
- 三个 tab:首页 → P1;蒸馏前任 → P4;我的专属创作者 → 未登录进登录页,已登录普通用户进 P2,已登录创作者进 U7 创作者管理中心。
|
||||||
|
- tabBar 在当前页面对应 tab 上高亮。
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 三个 tab 正确切换视图。
|
||||||
|
- 高亮状态与当前视图一致。
|
||||||
|
- 未登录时点击第三个 tab 先进登录页。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U9. 统一视图路由与状态管理
|
||||||
|
|
||||||
|
**Goal:** 让多视图切换更可靠,便于后续接入真实后端。
|
||||||
|
|
||||||
|
**Requirements:** 全局路由一致性。
|
||||||
|
|
||||||
|
**Dependencies:** U1–U8。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `app.js`
|
||||||
|
- `index.html`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- 维护 `views` 字典,所有视图统一 `showView(viewId)` 切换。
|
||||||
|
- `authState` 和 `creatorState` 集中到顶部,提供 `login()`, `logout()`, `bindCreator()` 方法。
|
||||||
|
- 每次视图切换时滚动到顶部,并更新 tabBar 高亮。
|
||||||
|
- 保留 URL hash 与当前视图同步(可选,便于刷新后定位)。
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 任意返回按钮都能回到正确上一页或首页。
|
||||||
|
- 刷新页面后仍保持登录态(localStorage)。
|
||||||
|
- 视图切换无 JavaScript 报错。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Key Technical Decisions
|
||||||
|
|
||||||
|
| 决策 | 选项 | 选择 | 理由 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 登录态存储 | localStorage / sessionStorage / 仅内存 | localStorage | 单页应用刷新后保持状态,后续后端接管时替换为 token 即可。 |
|
||||||
|
| 支付流程 | 真实支付接口 / 前端模拟 | 前端模拟 | PRD 附录列明「自动支付接口暂不开发」,一期仅做 UI 状态切换。 |
|
||||||
|
| 角色数据 | 硬编码 mock / localStorage / 后端 | 硬编码 mock + 可扩展结构 | 一期先展示前端,数据结构按后端接口预留字段。 |
|
||||||
|
| 创作者表单定位 | 删除 / 保留在创作者中心 | 保留在创作者中心 | 满足用户原始需求:生成 Soul.md 与 config.yaml。 |
|
||||||
|
| tabBar 实现 | 固定底部栏 / 无 tabBar | 固定底部栏 | 与 PRD 一致,也弥补 HTML5 缺少小程序原生导航的问题。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Scope Boundaries
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
- 首页四个入口的路由修正。
|
||||||
|
- P2、P3、P5、P6、P7 的页面结构与交互。
|
||||||
|
- 底部 tabBar。
|
||||||
|
- 前端模拟的登录态、创作者态、支付态。
|
||||||
|
|
||||||
|
### Deferred to Follow-Up Work
|
||||||
|
- 真实后端 API 对接(登录、支付、角色 CRUD、收入流水)。
|
||||||
|
- 文件上传(蒸馏前任聊天记录),PRD 已明确一期走微信人工指导。
|
||||||
|
- 自动提现审核与转账,PRD 已明确一期手动处理。
|
||||||
|
- 运营后台。
|
||||||
|
- 创作者页面颜色主题自定义。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Risks & Dependencies
|
||||||
|
|
||||||
|
| 风险 | 影响 | 缓解 |
|
||||||
|
|---|---|---|
|
||||||
|
| PRD 面向微信小程序,而当前是 HTML5 站点,部分交互(如微信扫码、小程序 tabBar)需要重新设计。 | 中 | 用 HTML5 可实现的等价交互替代:tabBar 用固定底部栏、扫码用二维码占位图、微信支付用模拟按钮。 |
|
||||||
|
| 创作者管理中心把现有人设蒸馏表单作为角色编辑页,表单字段可能与 PRD P7 的「角色管理页」字段不完全一致。 | 低 | 在 U6 中明确表单就是角色编辑工具,PRD P7 的其他字段(价格、用量、二维码)在角色列表中展示。 |
|
||||||
|
| 缺少真实后端,登录/支付状态容易在刷新或切换设备时丢失。 | 低 | 一期用 localStorage,并在计划中标注后续对接点。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Open Questions
|
||||||
|
|
||||||
|
1. 创作者入驻(P6)的负责人微信号和二维码是否已确定?需要真实素材占位还是先用示例图?
|
||||||
|
2. 角色库页(P2)的示例角色数据是否需要用户提供,还是先用 2–3 个虚构角色填充?
|
||||||
|
3. 首页「我的 XXX」中的 XXX 默认文案是否保留「我的 [XXX]」还是给一个默认占位如「我的角色库」?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Sources & Research
|
||||||
|
|
||||||
|
- `Eternal_AI_PRD_v1.docx` — 产品需求文档,定义 7 个页面及交互逻辑。
|
||||||
|
- 当前代码:`index.html`、`app.js`、`styles.css` — 现有单页应用结构与视图切换方式。
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const testData = require('./fixtures/test-data');
|
||||||
|
const { cleanDatabase, seedExistingUser, disconnect } = require('./fixtures/database');
|
||||||
|
|
||||||
|
test.describe('认证流程', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await cleanDatabase();
|
||||||
|
await seedExistingUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await cleanDatabase();
|
||||||
|
await disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('注册新用户成功', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
// 点击「我的 XXX」卡片进入 auth 视图(未登录时)
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
|
||||||
|
// 切换到注册 tab
|
||||||
|
await page.locator('.auth-tabs [data-tab="register"]').click();
|
||||||
|
// 等待注册表单的 fadeIn 动画完成
|
||||||
|
await expect(page.locator('[data-form="register"]')).toBeVisible();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
|
// 填写注册表单
|
||||||
|
await page.fill('[data-form="register"] [name="account"]', testData.users.newUser.account);
|
||||||
|
await page.fill('[data-form="register"] [name="password"]', testData.users.newUser.password);
|
||||||
|
await page.fill('[data-form="register"] [name="confirmPassword"]', testData.users.newUser.password);
|
||||||
|
|
||||||
|
// 提交
|
||||||
|
await page.locator('[data-form="register"] button[type="submit"]').click();
|
||||||
|
|
||||||
|
// 注册成功后自动成为创作者,应跳转到创作者中心
|
||||||
|
await expect(page.locator('#creator-center')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('重复注册同一账号失败', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
await page.locator('.auth-tabs [data-tab="register"]').click();
|
||||||
|
// 等待注册表单的 fadeIn 动画完成
|
||||||
|
await expect(page.locator('[data-form="register"]')).toBeVisible();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
|
await page.fill('[data-form="register"] [name="account"]', testData.users.existing.account);
|
||||||
|
await page.fill('[data-form="register"] [name="password"]', testData.users.existing.password);
|
||||||
|
await page.fill('[data-form="register"] [name="confirmPassword"]', testData.users.existing.password);
|
||||||
|
|
||||||
|
// 监听 dialog(错误信息通过 alert 弹出)
|
||||||
|
const dialogPromise = page.waitForEvent('dialog');
|
||||||
|
await page.locator('[data-form="register"] button[type="submit"]').click();
|
||||||
|
const dialog = await dialogPromise;
|
||||||
|
expect(dialog.message()).toContain('已注册');
|
||||||
|
await dialog.accept();
|
||||||
|
|
||||||
|
// 应仍停留在 auth 视图
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('登录已有用户成功', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
|
||||||
|
// 默认在登录 tab
|
||||||
|
await page.fill('[data-form="login"] [name="account"]', testData.users.existing.account);
|
||||||
|
await page.fill('[data-form="login"] [name="password"]', testData.users.existing.password);
|
||||||
|
await page.locator('[data-form="login"] button[type="submit"]').click();
|
||||||
|
|
||||||
|
// 登录成功后(existing 是创作者)应跳转到创作者中心
|
||||||
|
await expect(page.locator('#creator-center')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('错误密码登录失败', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
|
||||||
|
await page.fill('[data-form="login"] [name="account"]', testData.users.existing.account);
|
||||||
|
await page.fill('[data-form="login"] [name="password"]', 'wrongpassword');
|
||||||
|
|
||||||
|
const dialogPromise = page.waitForEvent('dialog');
|
||||||
|
await page.locator('[data-form="login"] button[type="submit"]').click();
|
||||||
|
const dialog = await dialogPromise;
|
||||||
|
expect(dialog.message()).toContain('账号或密码错误');
|
||||||
|
await dialog.accept();
|
||||||
|
|
||||||
|
// 应仍停留在 auth 视图
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('注册时密码不一致提示', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
await page.locator('.auth-tabs [data-tab="register"]').click();
|
||||||
|
// 等待注册表单的 fadeIn 动画完成
|
||||||
|
await expect(page.locator('[data-form="register"]')).toBeVisible();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
|
await page.fill('[data-form="register"] [name="account"]', 'mismatch_user');
|
||||||
|
await page.fill('[data-form="register"] [name="password"]', 'password1');
|
||||||
|
await page.fill('[data-form="register"] [name="confirmPassword"]', 'password2');
|
||||||
|
|
||||||
|
// 密码不一致通过原生 setCustomValidity + reportValidity 提示(非 alert)
|
||||||
|
await page.locator('[data-form="register"] button[type="submit"]').click();
|
||||||
|
|
||||||
|
// 验证 confirmPassword 字段的 validationMessage 包含「不一致」
|
||||||
|
const validationMessage = await page
|
||||||
|
.locator('[data-form="register"] [name="confirmPassword"]')
|
||||||
|
.evaluate((el) => el.validationMessage);
|
||||||
|
expect(validationMessage).toContain('不一致');
|
||||||
|
|
||||||
|
// 应仍停留在 auth 视图
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('登出后回到首页', async ({ page }) => {
|
||||||
|
// 先登录
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
await page.fill('[data-form="login"] [name="account"]', testData.users.existing.account);
|
||||||
|
await page.fill('[data-form="login"] [name="password"]', testData.users.existing.password);
|
||||||
|
await page.locator('[data-form="login"] button[type="submit"]').click();
|
||||||
|
await expect(page.locator('#creator-center')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// 切换到设置 tab(登出按钮在设置面板)
|
||||||
|
await page.locator('[data-center-tab="settings"]').click();
|
||||||
|
await expect(page.locator('#center-settings')).toBeVisible();
|
||||||
|
|
||||||
|
// 点击登出
|
||||||
|
await page.locator('[data-action="logout"]').click();
|
||||||
|
|
||||||
|
// 应回到首页
|
||||||
|
await expect(page.locator('#landing')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('登录态持久化(刷新页面后仍登录)', async ({ page }) => {
|
||||||
|
// 先登录
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
await page.fill('[data-form="login"] [name="account"]', testData.users.existing.account);
|
||||||
|
await page.fill('[data-form="login"] [name="password"]', testData.users.existing.password);
|
||||||
|
await page.locator('[data-form="login"] button[type="submit"]').click();
|
||||||
|
await expect(page.locator('#creator-center')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// 刷新页面
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// 首页应可见,且卡片按钮文案应变化(不再是「登录 / 注册」)
|
||||||
|
await expect(page.locator('#landing')).toBeVisible();
|
||||||
|
await expect(page.locator('#characters-btn')).not.toHaveText(/登录/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,240 @@
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const testData = require('./fixtures/test-data');
|
||||||
|
const { cleanDatabase, seedExistingUser, disconnect, prisma } = require('./fixtures/database');
|
||||||
|
|
||||||
|
test.describe('创作者中心与角色发布/编辑', () => {
|
||||||
|
let existingUser;
|
||||||
|
let existingRole;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await cleanDatabase();
|
||||||
|
await seedExistingUser();
|
||||||
|
existingUser = await prisma.user.findUnique({
|
||||||
|
where: { account: testData.users.existing.account },
|
||||||
|
});
|
||||||
|
|
||||||
|
existingRole = await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
creatorId: existingUser.id,
|
||||||
|
displayName: '已有角色',
|
||||||
|
gender: 'female',
|
||||||
|
personality: '温柔体贴',
|
||||||
|
background: '来自南方',
|
||||||
|
speechStyle: '轻声细语',
|
||||||
|
greeting: '你好~',
|
||||||
|
desc: '温柔体贴的角色',
|
||||||
|
price: 19.9,
|
||||||
|
status: 'running',
|
||||||
|
temperature: 0.8,
|
||||||
|
maxTokens: 2048,
|
||||||
|
enableMemory: true,
|
||||||
|
enableTools: false,
|
||||||
|
model: 'gpt-4o',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await cleanDatabase();
|
||||||
|
await disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loginAsExisting(page) {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
await page.fill('[data-form="login"] [name="account"]', testData.users.existing.account);
|
||||||
|
await page.fill('[data-form="login"] [name="password"]', testData.users.existing.password);
|
||||||
|
await page.locator('[data-form="login"] button[type="submit"]').click();
|
||||||
|
await expect(page.locator('#creator-center')).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('创作者中心显示三个 tab', async ({ page }) => {
|
||||||
|
await loginAsExisting(page);
|
||||||
|
await expect(page.locator('[data-center-tab="roles"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-center-tab="income"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-center-tab="settings"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('角色 tab 显示已有角色', async ({ page }) => {
|
||||||
|
await loginAsExisting(page);
|
||||||
|
// 默认在 roles tab
|
||||||
|
await expect(page.locator('#creator-role-list .role-card')).toHaveCount(1, { timeout: 5000 });
|
||||||
|
await expect(page.locator('#creator-role-list .role-card__name')).toHaveText('已有角色');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('收入 tab 显示余额和流水', async ({ page }) => {
|
||||||
|
await loginAsExisting(page);
|
||||||
|
await page.locator('[data-center-tab="income"]').click();
|
||||||
|
// income 面板可见(#center-income 带 active class)
|
||||||
|
await expect(page.locator('#center-income')).toBeVisible();
|
||||||
|
// 余额元素可见
|
||||||
|
await expect(page.locator('#income-balance')).toBeVisible({ timeout: 5000 });
|
||||||
|
// 流水列表容器存在
|
||||||
|
await expect(page.locator('#income-list')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('设置 tab 可保存昵称和库名', async ({ page }) => {
|
||||||
|
await loginAsExisting(page);
|
||||||
|
await page.locator('[data-center-tab="settings"]').click();
|
||||||
|
await expect(page.locator('#center-settings')).toBeVisible();
|
||||||
|
|
||||||
|
// 修改设置
|
||||||
|
await page.fill('#settings-form [name="creatorName"]', '新昵称');
|
||||||
|
await page.fill('#settings-form [name="libraryName"]', '新库名');
|
||||||
|
|
||||||
|
// 保存成功会弹 alert「设置已保存」
|
||||||
|
const dialogPromise = page.waitForEvent('dialog');
|
||||||
|
await page.locator('#settings-form button[type="submit"]').click();
|
||||||
|
const dialog = await dialogPromise;
|
||||||
|
expect(dialog.message()).toContain('保存');
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('新建角色完整流程(4步表单)', async ({ page }) => {
|
||||||
|
await loginAsExisting(page);
|
||||||
|
|
||||||
|
// 点击新建角色
|
||||||
|
await page.locator('[data-action="new-role"]').click();
|
||||||
|
await expect(page.locator('#creator')).toBeVisible();
|
||||||
|
|
||||||
|
// Step 1: 基础身份(agentId 必填,pattern [a-zA-Z0-9_]+;displayName 必填)
|
||||||
|
await page.fill('#character-form [name="agentId"]', 'e2e_test_role');
|
||||||
|
await page.fill('#character-form [name="displayName"]', testData.role.displayName);
|
||||||
|
await page.selectOption('#character-form [name="gender"]', testData.role.gender);
|
||||||
|
await page.fill('#character-form [name="age"]', testData.role.age);
|
||||||
|
await page.locator('.form-step.active [data-action="next"]').click();
|
||||||
|
|
||||||
|
// Step 2: 灵魂设定(background / personality / speechStyle 必填)
|
||||||
|
await page.fill('#character-form [name="background"]', testData.role.background);
|
||||||
|
await page.fill('#character-form [name="personality"]', testData.role.personality);
|
||||||
|
await page.fill('#character-form [name="speechStyle"]', testData.role.speechStyle);
|
||||||
|
await page.fill('#character-form [name="likes"]', testData.role.likes);
|
||||||
|
await page.fill('#character-form [name="dislikes"]', testData.role.dislikes);
|
||||||
|
await page.locator('.form-step.active [data-action="next"]').click();
|
||||||
|
|
||||||
|
// Step 3: 关系与记忆(greeting 必填)
|
||||||
|
await page.fill('#character-form [name="relationship"]', testData.role.relationship);
|
||||||
|
await page.fill('#character-form [name="memories"]', testData.role.memories);
|
||||||
|
await page.fill('#character-form [name="secrets"]', testData.role.secrets);
|
||||||
|
await page.fill('#character-form [name="greeting"]', testData.role.greeting);
|
||||||
|
await page.locator('.form-step.active [data-action="next"]').click();
|
||||||
|
|
||||||
|
// Step 4: 运行配置(model/temperature/maxTokens 有默认值;无 price 字段)
|
||||||
|
// 捕获可能的错误弹窗
|
||||||
|
let dialogMessage = '';
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
dialogMessage = dialog.message();
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
await page.locator('.form-step.active [data-action="publish"]').click();
|
||||||
|
|
||||||
|
// 应显示生成结果面板
|
||||||
|
await expect(page.locator('#result-panel')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// 验证 Soul.md 内容(单个 #preview-code 元素,默认显示 soul)
|
||||||
|
await expect(page.locator('#preview-code')).toBeVisible();
|
||||||
|
const soulContent = await page.locator('#preview-code').textContent();
|
||||||
|
expect(soulContent).toContain(testData.role.displayName);
|
||||||
|
// generateSoulMd 会把 personality 按 [,,] 拆分成 " | " 连接的标签
|
||||||
|
const personalityTag = testData.role.personality.split(/[,,]/)[0].trim();
|
||||||
|
expect(soulContent).toContain(personalityTag);
|
||||||
|
|
||||||
|
// 切换到 config.yaml 预览(preview-tabs 内的 [data-tab="config"])
|
||||||
|
await page.locator('.preview-tabs [data-tab="config"]').click();
|
||||||
|
const configContent = await page.locator('#preview-code').textContent();
|
||||||
|
expect(configContent).toContain('model:');
|
||||||
|
expect(configContent).toContain('temperature:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('新建角色后数据库有记录', async ({ page }) => {
|
||||||
|
await loginAsExisting(page);
|
||||||
|
await page.locator('[data-action="new-role"]').click();
|
||||||
|
await expect(page.locator('#creator')).toBeVisible();
|
||||||
|
|
||||||
|
// Step 1: 填写最小必填字段
|
||||||
|
await page.fill('#character-form [name="agentId"]', 'e2e_db_role');
|
||||||
|
await page.fill('#character-form [name="displayName"]', 'DB验证角色');
|
||||||
|
await page.selectOption('#character-form [name="gender"]', 'female');
|
||||||
|
await page.locator('.form-step.active [data-action="next"]').click();
|
||||||
|
|
||||||
|
// Step 2
|
||||||
|
await page.fill('#character-form [name="personality"]', '测试性格');
|
||||||
|
await page.fill('#character-form [name="background"]', '测试背景');
|
||||||
|
await page.fill('#character-form [name="speechStyle"]', '测试风格');
|
||||||
|
await page.locator('.form-step.active [data-action="next"]').click();
|
||||||
|
|
||||||
|
// Step 3
|
||||||
|
await page.fill('#character-form [name="greeting"]', '测试问候');
|
||||||
|
await page.locator('.form-step.active [data-action="next"]').click();
|
||||||
|
|
||||||
|
// Step 4: 直接发布(无 price 字段,使用默认值)
|
||||||
|
let dialogMessage = '';
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
dialogMessage = dialog.message();
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
await page.locator('.form-step.active [data-action="publish"]').click();
|
||||||
|
await expect(page.locator('#result-panel')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// 验证数据库
|
||||||
|
const dbRole = await prisma.role.findFirst({
|
||||||
|
where: { displayName: 'DB验证角色' },
|
||||||
|
});
|
||||||
|
expect(dbRole).not.toBeNull();
|
||||||
|
expect(dbRole.personality).toBe('测试性格');
|
||||||
|
expect(dbRole.greeting).toBe('测试问候');
|
||||||
|
expect(dbRole.soulMd).not.toBeNull();
|
||||||
|
expect(dbRole.configYaml).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('编辑角色加载已有数据', async ({ page }) => {
|
||||||
|
await loginAsExisting(page);
|
||||||
|
|
||||||
|
// 等待角色列表加载,找到已 seeded 的角色(其他测试可能已创建额外角色)
|
||||||
|
await expect(page.locator('#creator-role-list .role-card')).not.toHaveCount(0, { timeout: 5000 });
|
||||||
|
// 定位到"已有角色"那张卡片的编辑按钮
|
||||||
|
const seededCard = page.locator('#creator-role-list .role-card', { hasText: '已有角色' }).first();
|
||||||
|
await expect(seededCard).toBeVisible({ timeout: 5000 });
|
||||||
|
await seededCard.locator('[data-action="edit-role"]').click();
|
||||||
|
await expect(page.locator('#creator')).toBeVisible();
|
||||||
|
|
||||||
|
// 验证表单已预填(loadRoleForEdit 会拉取 /roles/:id/full 填充表单)
|
||||||
|
await expect(page.locator('#character-form [name="displayName"]')).toHaveValue('已有角色', { timeout: 5000 });
|
||||||
|
await expect(page.locator('#character-form [name="personality"]')).toHaveValue('温柔体贴');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('表单验证 - 必填字段为空时阻止提交', async ({ page }) => {
|
||||||
|
await loginAsExisting(page);
|
||||||
|
await page.locator('[data-action="new-role"]').click();
|
||||||
|
await expect(page.locator('#creator')).toBeVisible();
|
||||||
|
|
||||||
|
// 不填任何字段直接点下一步
|
||||||
|
await page.locator('.form-step.active [data-action="next"]').click();
|
||||||
|
|
||||||
|
// 应仍停留在 step 0(agentId / displayName 必填,验证失败)
|
||||||
|
// step 0 仍 active => agentId 字段仍可见
|
||||||
|
await expect(page.locator('#character-form [name="agentId"]')).toBeVisible();
|
||||||
|
await expect(page.locator('#character-form [name="displayName"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('步骤导航 - 上一步按钮', async ({ page }) => {
|
||||||
|
await loginAsExisting(page);
|
||||||
|
await page.locator('[data-action="new-role"]').click();
|
||||||
|
await expect(page.locator('#creator')).toBeVisible();
|
||||||
|
|
||||||
|
// 填写 step 1 并前进
|
||||||
|
await page.fill('#character-form [name="agentId"]', 'e2e_nav_test');
|
||||||
|
await page.fill('#character-form [name="displayName"]', '导航测试');
|
||||||
|
await page.selectOption('#character-form [name="gender"]', 'female');
|
||||||
|
await page.locator('.form-step.active [data-action="next"]').click();
|
||||||
|
|
||||||
|
// 进入 step 2 后点上一步(data-action="prev",非 prev-step)
|
||||||
|
await expect(page.locator('#character-form [name="background"]')).toBeVisible();
|
||||||
|
await page.locator('.form-step.active [data-action="prev"]').click();
|
||||||
|
|
||||||
|
// 应回到 step 1,且数据保留
|
||||||
|
await expect(page.locator('#character-form [name="displayName"]')).toBeVisible();
|
||||||
|
await expect(page.locator('#character-form [name="displayName"]')).toHaveValue('导航测试');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
// 数据库清理 helper
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function cleanDatabase() {
|
||||||
|
await prisma.order.deleteMany();
|
||||||
|
await prisma.role.deleteMany();
|
||||||
|
await prisma.user.deleteMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedExistingUser() {
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const testData = require('./test-data');
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
account: testData.users.existing.account,
|
||||||
|
password: bcrypt.hashSync(testData.users.existing.password, 10),
|
||||||
|
isCreator: true,
|
||||||
|
creatorName: testData.users.existing.account,
|
||||||
|
libraryName: 'E2E测试库',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnect() {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { cleanDatabase, seedExistingUser, disconnect, prisma };
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// 测试数据 fixtures
|
||||||
|
const testData = {
|
||||||
|
users: {
|
||||||
|
newUser: {
|
||||||
|
account: `e2e_${Date.now()}`,
|
||||||
|
password: 'Test123456',
|
||||||
|
},
|
||||||
|
existing: {
|
||||||
|
account: 'e2e_existing',
|
||||||
|
password: 'Test123456',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
displayName: 'E2E测试角色',
|
||||||
|
gender: 'female',
|
||||||
|
age: '25',
|
||||||
|
relationship: '女友',
|
||||||
|
personality: '温柔,体贴,善解人意',
|
||||||
|
background: '来自江南水乡的女孩,喜欢读书和绘画',
|
||||||
|
speechStyle: '轻声细语,偶尔带点小俏皮',
|
||||||
|
likes: '读书,绘画,猫咪',
|
||||||
|
dislikes: '吵闹,谎言',
|
||||||
|
memories: '一起看过的樱花,第一次约会',
|
||||||
|
secrets: '其实很怕打雷',
|
||||||
|
greeting: '你回来啦~今天过得怎么样?',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
temperature: '0.8',
|
||||||
|
maxTokens: '2048',
|
||||||
|
price: '29.9',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = testData;
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const { cleanDatabase, disconnect, prisma } = require('./fixtures/database');
|
||||||
|
|
||||||
|
test.describe('导航与可访问性', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await cleanDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await cleanDatabase();
|
||||||
|
await disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('首页显示两张卡片', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('#landing')).toBeVisible();
|
||||||
|
// 两张卡片:open-characters 和 open-distill
|
||||||
|
const cards = page.locator('[data-action="open-characters"], [data-action="open-distill"]');
|
||||||
|
await expect(cards).toHaveCount(2, { timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('底部 tabBar 三个 tab 可见', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('.tab-bar')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-tab-action="tab-home"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-tab-action="tab-distill"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-tab-action="tab-mine"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tabBar 切换视图', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// 点击蒸馏前任 tab
|
||||||
|
await page.locator('[data-tab-action="tab-distill"]').click();
|
||||||
|
await expect(page.locator('#distill')).toBeVisible();
|
||||||
|
|
||||||
|
// 点击首页 tab
|
||||||
|
await page.locator('[data-tab-action="tab-home"]').click();
|
||||||
|
await expect(page.locator('#landing')).toBeVisible();
|
||||||
|
|
||||||
|
// 点击我的 tab(未登录应跳转到 auth)
|
||||||
|
await page.locator('[data-tab-action="tab-mine"]').click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('首页卡片点击进入对应视图', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// 点击蒸馏前任卡片
|
||||||
|
await page.locator('[data-action="open-distill"]').first().click();
|
||||||
|
await expect(page.locator('#distill')).toBeVisible();
|
||||||
|
|
||||||
|
// 返回首页
|
||||||
|
await page.locator('[data-tab-action="tab-home"]').click();
|
||||||
|
await expect(page.locator('#landing')).toBeVisible();
|
||||||
|
|
||||||
|
// 点击「我的 XXX」卡片(未登录进入 auth)
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('关于页面可访问', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-about"]').first().click();
|
||||||
|
await expect(page.locator('#about')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('创作者入驻页面可访问', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-onboarding"]').first().click();
|
||||||
|
await expect(page.locator('#onboarding')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FAQ 折叠展开', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-about"]').first().click();
|
||||||
|
await expect(page.locator('#about')).toBeVisible();
|
||||||
|
|
||||||
|
// 点击第一个 FAQ 按钮(.faq-q,非 .faq-item__question)
|
||||||
|
const faqButton = page.locator('.faq-q').first();
|
||||||
|
await faqButton.click();
|
||||||
|
|
||||||
|
// 验证 aria-expanded 变为 true
|
||||||
|
await expect(faqButton).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
|
||||||
|
// 再次点击折叠
|
||||||
|
await faqButton.click();
|
||||||
|
await expect(faqButton).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('返回按钮基于历史记录导航', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// 导航路径: landing → about → 返回
|
||||||
|
await page.locator('[data-action="open-about"]').first().click();
|
||||||
|
await expect(page.locator('#about')).toBeVisible();
|
||||||
|
|
||||||
|
// about 视图使用 data-action="back"
|
||||||
|
await page.locator('#about [data-action="back"]').click();
|
||||||
|
await expect(page.locator('#landing')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('跳过链接(a11y)存在且可用', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const skipLink = page.locator('.skip-link');
|
||||||
|
// 跳过链接应存在
|
||||||
|
await expect(skipLink).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tabBar 有正确的 ARIA 角色', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const tabbar = page.locator('.tab-bar');
|
||||||
|
await expect(tabbar).toHaveAttribute('role', 'tablist');
|
||||||
|
|
||||||
|
// tab-bar 内的按钮应有 role="tab"
|
||||||
|
const tabs = page.locator('.tab-bar [data-tab-action]');
|
||||||
|
const count = await tabs.count();
|
||||||
|
expect(count).toBe(3);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
await expect(tabs.nth(i)).toHaveAttribute('role', 'tab');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('视图切换时 aria-live 播报', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
// #sr-announce 是 aria-live 区域,role="status" aria-live="polite"
|
||||||
|
const liveRegion = page.locator('#sr-announce');
|
||||||
|
await expect(liveRegion).toHaveAttribute('aria-live', 'polite');
|
||||||
|
await expect(liveRegion).toHaveAttribute('role', 'status');
|
||||||
|
|
||||||
|
// 切换视图后 live region 应有内容
|
||||||
|
await page.locator('[data-action="open-about"]').first().click();
|
||||||
|
await expect(liveRegion).not.toHaveText('');
|
||||||
|
const liveText = await liveRegion.textContent();
|
||||||
|
expect(liveText.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('角色卡片支持键盘操作', async ({ page }) => {
|
||||||
|
// 创建非创作者用户 + 角色
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
account: 'e2e_keyboard_user',
|
||||||
|
password: bcrypt.hashSync('Test123456', 10),
|
||||||
|
isCreator: false,
|
||||||
|
libraryName: '键盘测试库',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
creatorId: user.id,
|
||||||
|
displayName: '键盘测试角色',
|
||||||
|
gender: 'female',
|
||||||
|
personality: '测试',
|
||||||
|
background: '测试',
|
||||||
|
speechStyle: '测试',
|
||||||
|
greeting: '测试',
|
||||||
|
desc: '键盘测试',
|
||||||
|
price: 9.9,
|
||||||
|
status: 'running',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 登录非创作者(登录后自动进入角色库)
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
await page.fill('[data-form="login"] [name="account"]', 'e2e_keyboard_user');
|
||||||
|
await page.fill('[data-form="login"] [name="password"]', 'Test123456');
|
||||||
|
await page.locator('[data-form="login"] button[type="submit"]').click();
|
||||||
|
await expect(page.locator('#role-library')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// 聚焦角色卡片(role-card 有 tabindex="0")
|
||||||
|
const roleCard = page.locator('#role-list .role-card').first();
|
||||||
|
await roleCard.focus();
|
||||||
|
|
||||||
|
// 按 Enter 应进入详情
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await expect(page.locator('#role-detail')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
await prisma.role.deleteMany();
|
||||||
|
await prisma.user.deleteMany();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('表单 label 与 input 关联', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
|
||||||
|
// 检查登录表单的 input 存在
|
||||||
|
const accountInput = page.locator('[data-form="login"] [name="account"]');
|
||||||
|
await expect(accountInput).toBeVisible();
|
||||||
|
|
||||||
|
// 检查是否有对应的 label(包裹式 label 包含账号相关文案)
|
||||||
|
const labelText = await page.locator('label').filter({ hasText: /账号|用户名|Account/i }).count();
|
||||||
|
expect(labelText).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('focus-visible 样式存在', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
// Tab 到第一个可聚焦元素
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
// 聚焦元素应有可见的焦点样式(:focus-visible 使用 outline)
|
||||||
|
const focused = await page.evaluate(() => {
|
||||||
|
const el = document.activeElement;
|
||||||
|
if (!el) return false;
|
||||||
|
const styles = window.getComputedStyle(el);
|
||||||
|
return styles.outlineStyle !== 'none' || styles.boxShadow !== 'none';
|
||||||
|
});
|
||||||
|
expect(focused).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const testData = require('./fixtures/test-data');
|
||||||
|
const { cleanDatabase, seedExistingUser, disconnect, prisma } = require('./fixtures/database');
|
||||||
|
|
||||||
|
// 非创作者用户(用于角色库测试)
|
||||||
|
const NON_CREATOR = {
|
||||||
|
account: 'e2e_noncreator',
|
||||||
|
password: 'Test123456',
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('角色库与角色详情', () => {
|
||||||
|
let existingUser;
|
||||||
|
let testRole;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await cleanDatabase();
|
||||||
|
await seedExistingUser();
|
||||||
|
existingUser = await prisma.user.findUnique({
|
||||||
|
where: { account: testData.users.existing.account },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建非创作者用户(open-characters 对非创作者进入 role-library)
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
account: NON_CREATOR.account,
|
||||||
|
password: bcrypt.hashSync(NON_CREATOR.password, 10),
|
||||||
|
isCreator: false,
|
||||||
|
libraryName: 'E2E测试库',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建一个测试角色(status=running 才会在角色库展示)
|
||||||
|
testRole = await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
creatorId: existingUser.id,
|
||||||
|
displayName: '测试角色A',
|
||||||
|
gender: 'female',
|
||||||
|
age: '24',
|
||||||
|
relationship: '女友',
|
||||||
|
personality: '温柔可爱,善解人意',
|
||||||
|
background: '来自海边的小镇女孩',
|
||||||
|
speechStyle: '轻柔细腻',
|
||||||
|
greeting: '你好呀~',
|
||||||
|
desc: '温柔可爱的女友角色',
|
||||||
|
price: 29.9,
|
||||||
|
status: 'running',
|
||||||
|
avatar: 'https://example.com/avatar.png',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await cleanDatabase();
|
||||||
|
await disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 以非创作者身份登录,登录后自动进入角色库
|
||||||
|
async function loginAsNonCreator(page) {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
// 确保登录 tab 激活
|
||||||
|
await page.locator('.auth-tabs [data-tab="login"]').click();
|
||||||
|
await page.locator('[data-form="login"]').waitFor();
|
||||||
|
await page.fill('[data-form="login"] [name="account"]', NON_CREATOR.account);
|
||||||
|
await page.fill('[data-form="login"] [name="password"]', NON_CREATOR.password);
|
||||||
|
await page.locator('[data-form="login"] button[type="submit"]').click();
|
||||||
|
await expect(page.locator('#role-library')).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('角色库显示已上架角色', async ({ page }) => {
|
||||||
|
await loginAsNonCreator(page);
|
||||||
|
|
||||||
|
// 应显示测试角色
|
||||||
|
await expect(page.locator('#role-list .role-card')).toHaveCount(1, { timeout: 5000 });
|
||||||
|
await expect(page.locator('#role-list .role-card__name')).toHaveText('测试角色A');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空角色库显示空状态', async ({ page }) => {
|
||||||
|
// 清空角色
|
||||||
|
await prisma.role.deleteMany();
|
||||||
|
|
||||||
|
await loginAsNonCreator(page);
|
||||||
|
|
||||||
|
// 应显示空状态
|
||||||
|
await expect(page.locator('#library-empty')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('点击角色卡片进入详情页', async ({ page }) => {
|
||||||
|
// 重新创建角色
|
||||||
|
testRole = await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
creatorId: existingUser.id,
|
||||||
|
displayName: '详情测试角色',
|
||||||
|
gender: 'male',
|
||||||
|
personality: '阳光开朗',
|
||||||
|
background: '运动少年',
|
||||||
|
speechStyle: '热情直接',
|
||||||
|
greeting: '嘿!你好!',
|
||||||
|
desc: '阳光开朗的男孩',
|
||||||
|
price: 19.9,
|
||||||
|
status: 'running',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await loginAsNonCreator(page);
|
||||||
|
|
||||||
|
// 点击角色卡片
|
||||||
|
await page.locator('#role-list .role-card').first().click();
|
||||||
|
await expect(page.locator('#role-detail')).toBeVisible();
|
||||||
|
|
||||||
|
// 验证详情内容
|
||||||
|
await expect(page.locator('#detail-name')).toHaveText('详情测试角色');
|
||||||
|
await expect(page.locator('#detail-role-name')).toHaveText('详情测试角色');
|
||||||
|
await expect(page.locator('#detail-price')).toContainText('19.9');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('角色详情页返回按钮', async ({ page }) => {
|
||||||
|
await loginAsNonCreator(page);
|
||||||
|
|
||||||
|
await page.locator('#role-list .role-card').first().click();
|
||||||
|
await expect(page.locator('#role-detail')).toBeVisible();
|
||||||
|
|
||||||
|
// 点击返回(role-detail 使用 back-to-library)
|
||||||
|
await page.locator('[data-action="back-to-library"]').click();
|
||||||
|
await expect(page.locator('#role-library')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('角色详情付款流程', async ({ page }) => {
|
||||||
|
await loginAsNonCreator(page);
|
||||||
|
|
||||||
|
await page.locator('#role-list .role-card').first().click();
|
||||||
|
await expect(page.locator('#role-detail')).toBeVisible();
|
||||||
|
|
||||||
|
// 付款前:pre-pay 按钮区可见,paid 区隐藏
|
||||||
|
await expect(page.locator('#detail-actions-pre')).toBeVisible();
|
||||||
|
await expect(page.locator('#detail-paid')).toBeHidden();
|
||||||
|
|
||||||
|
// 点击付款按钮(data-action="pay",非 pay-role)
|
||||||
|
await page.locator('[data-action="pay"]').click();
|
||||||
|
|
||||||
|
// 应显示已付款区域(含二维码 / 头像下载)
|
||||||
|
await expect(page.locator('#detail-paid')).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.locator('#detail-actions-pre')).toBeHidden();
|
||||||
|
await expect(page.locator('[data-action="download-avatar"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'eternalai',
|
||||||
|
script: 'server.js',
|
||||||
|
cwd: __dirname,
|
||||||
|
instances: 1,
|
||||||
|
exec_mode: 'fork',
|
||||||
|
autorestart: true,
|
||||||
|
max_restarts: 10,
|
||||||
|
restart_delay: 3000,
|
||||||
|
max_memory_restart: '512M',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
},
|
||||||
|
error_file: './logs/err.log',
|
||||||
|
out_file: './logs/out.log',
|
||||||
|
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||||
|
merge_logs: true,
|
||||||
|
time: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
529
index.html
529
index.html
|
|
@ -7,19 +7,20 @@
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
|
||||||
<link rel="stylesheet" href="styles.css?v=5" />
|
<link rel="stylesheet" href="styles.css?v=7" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<a href="#landing" class="skip-link">跳到主内容</a>
|
||||||
<main class="app">
|
<main class="app">
|
||||||
<!-- Landing View -->
|
<!-- P1: Landing View -->
|
||||||
<section id="landing" class="view view--landing active">
|
<section id="landing" class="view view--landing active">
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
<article class="card card--characters" data-action="open-auth">
|
<article class="card card--characters" data-action="open-characters">
|
||||||
<div class="card__frame" aria-hidden="true"></div>
|
<div class="card__frame" aria-hidden="true"></div>
|
||||||
<div class="card__content">
|
<div class="card__content">
|
||||||
<h2 class="card__title">我的 [XXX ]</h2>
|
<h2 class="card__title" id="library-name">我的 [XXX]</h2>
|
||||||
<p class="card__desc">登录后管理你的角色</p>
|
<p class="card__desc" id="characters-desc">登录后管理你的角色</p>
|
||||||
<button class="btn btn--outline" type="button">登录 / 注册</button>
|
<button class="btn btn--outline" type="button" id="characters-btn">登录 / 注册</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|
@ -37,18 +38,381 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="landing-footer">
|
<footer class="landing-footer">
|
||||||
<a href="#about" class="footer-link">关于 Eternal AI →</a>
|
<a href="#" class="footer-link" data-action="open-about">关于 Eternal AI →</a>
|
||||||
<a href="#creator" class="footer-link" data-action="open-creator">我是创作者,申请入驻 →</a>
|
<a href="#" class="footer-link" data-action="open-onboarding">我是创作者,申请入驻 →</a>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Creator View -->
|
<!-- Auth View (Login / Register) -->
|
||||||
<section id="creator" class="view view--creator">
|
<section id="auth" class="view view--auth">
|
||||||
<header class="creator-header">
|
<header class="creator-header">
|
||||||
<button class="icon-btn" type="button" data-action="back" aria-label="返回">←</button>
|
<button class="icon-btn" type="button" data-action="back" aria-label="返回">←</button>
|
||||||
<div class="creator-brand">
|
<div class="creator-brand">
|
||||||
<span class="creator-brand__name">Eternal AI</span>
|
<span class="creator-brand__name">Eternal AI</span>
|
||||||
<span class="creator-brand__step">人设蒸馏器</span>
|
<span class="creator-brand__step">登录 / 注册</span>
|
||||||
|
</div>
|
||||||
|
<div class="stepper" aria-hidden="true"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="auth-tabs" role="tablist" aria-label="登录与注册">
|
||||||
|
<button class="auth-tab active" type="button" role="tab" aria-selected="true" data-tab="login">登录</button>
|
||||||
|
<button class="auth-tab" type="button" role="tab" aria-selected="false" data-tab="register">注册</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="login-form" class="auth-form active" autocomplete="on" data-form="login">
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">手机号 / 用户名</span>
|
||||||
|
<input class="field__input" name="account" type="text" autocomplete="username" placeholder="请输入手机号或用户名" required />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">密码</span>
|
||||||
|
<input class="field__input" name="password" type="password" autocomplete="current-password" placeholder="请输入密码" required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn--primary btn--block" type="submit">登录</button>
|
||||||
|
</div>
|
||||||
|
<p class="auth-hint">登录后可查看角色库、管理已订阅的角色。</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form id="register-form" class="auth-form" autocomplete="on" data-form="register">
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">手机号 / 用户名</span>
|
||||||
|
<input class="field__input" name="account" type="text" autocomplete="username" placeholder="设置登录账号" required />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">密码</span>
|
||||||
|
<input class="field__input" name="password" type="password" autocomplete="new-password" placeholder="设置密码" required minlength="6" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">确认密码</span>
|
||||||
|
<input class="field__input" name="confirmPassword" type="password" autocomplete="new-password" placeholder="再次输入密码" required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn--primary btn--block" type="submit">注册</button>
|
||||||
|
</div>
|
||||||
|
<p class="auth-hint">注册即代表同意《用户协议》和《隐私政策》。</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- P2: Role Library View -->
|
||||||
|
<section id="role-library" class="view view--role-library">
|
||||||
|
<header class="creator-header">
|
||||||
|
<button class="icon-btn" type="button" data-action="back" aria-label="返回">←</button>
|
||||||
|
<div class="creator-brand">
|
||||||
|
<span class="creator-brand__name" id="library-title">我的角色库</span>
|
||||||
|
<span class="creator-brand__step">选择一个角色开始对话</span>
|
||||||
|
</div>
|
||||||
|
<div class="stepper" aria-hidden="true"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="role-list" id="role-list" aria-live="polite" aria-label="角色列表"></div>
|
||||||
|
|
||||||
|
<div class="empty-state" id="library-empty" hidden>
|
||||||
|
<p class="empty-state__text">你还没有绑定专属创作者</p>
|
||||||
|
<p class="empty-state__hint">通过创作者的专属链接进入即可绑定</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- P3: Role Detail View -->
|
||||||
|
<section id="role-detail" class="view view--role-detail">
|
||||||
|
<header class="creator-header">
|
||||||
|
<button class="icon-btn" type="button" data-action="back-to-library" aria-label="返回">←</button>
|
||||||
|
<div class="creator-brand">
|
||||||
|
<span class="creator-brand__name" id="detail-name">角色详情</span>
|
||||||
|
<span class="creator-brand__step">了解 ta 并订阅</span>
|
||||||
|
</div>
|
||||||
|
<div class="stepper" aria-hidden="true"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="detail-page" id="detail-page">
|
||||||
|
<div class="detail-hero" id="detail-hero"></div>
|
||||||
|
<h2 class="detail-title" id="detail-role-name"></h2>
|
||||||
|
<p class="detail-desc" id="detail-role-desc"></p>
|
||||||
|
<div class="detail-price" id="detail-price"></div>
|
||||||
|
|
||||||
|
<div class="form-actions" id="detail-actions-pre">
|
||||||
|
<button class="btn btn--primary btn--block" type="button" data-action="pay">立即订阅</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-paid" id="detail-paid" hidden>
|
||||||
|
<div class="detail-qr" id="detail-qr"></div>
|
||||||
|
<p class="detail-paid__hint">扫码添加后,请将下方头像保存并设置为该联系人的备注头像,获得更完整的体验</p>
|
||||||
|
<div class="detail-avatar" id="detail-avatar"></div>
|
||||||
|
<button class="btn btn--outline btn--block" type="button" data-action="download-avatar">下载角色头像</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- P4: Distill Ex View -->
|
||||||
|
<section id="distill" class="view view--distill-page">
|
||||||
|
<header class="creator-header">
|
||||||
|
<button class="icon-btn" type="button" data-action="back" aria-label="返回">←</button>
|
||||||
|
<div class="creator-brand">
|
||||||
|
<span class="creator-brand__name">Eternal AI</span>
|
||||||
|
<span class="creator-brand__step">蒸馏前任</span>
|
||||||
|
</div>
|
||||||
|
<div class="stepper" aria-hidden="true"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="distill-page">
|
||||||
|
<a href="#" class="distill-quick" data-action="contact-wechat">没耐心?直接加微信定制沟通 →</a>
|
||||||
|
|
||||||
|
<div class="distill-section">
|
||||||
|
<h3 class="distill-section__title">什么是蒸馏前任?</h3>
|
||||||
|
<p class="distill-section__text">把你们曾经的聊天记录、语音习惯、相处细节交给我们,技术团队会将其蒸馏成一个可对话的 AI 前任。ta 会记得你们的暗号、说话节奏,甚至那些只有你们懂的小情绪。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="distill-section">
|
||||||
|
<h3 class="distill-section__title">适合谁?</h3>
|
||||||
|
<ul class="distill-list">
|
||||||
|
<li>想好好告别,却还没说完话的人</li>
|
||||||
|
<li>希望把一段关系以安全方式封存的人</li>
|
||||||
|
<li>想借 AI 完成自我疗愈、练习表达的人</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="distill-section">
|
||||||
|
<h3 class="distill-section__title">服务流程</h3>
|
||||||
|
<ol class="distill-steps">
|
||||||
|
<li><span>①</span> 下单付款</li>
|
||||||
|
<li><span>②</span> 客服指导导出聊天记录</li>
|
||||||
|
<li><span>③</span> 上传至指定云盘</li>
|
||||||
|
<li><span>④</span> 技术人员处理蒸馏</li>
|
||||||
|
<li><span>⑤</span> 完成后获得专属二维码和头像</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="distill-price">
|
||||||
|
<span class="distill-price__label">标准版</span>
|
||||||
|
<span class="distill-price__value">¥ 199</span>
|
||||||
|
<span class="distill-price__unit">/ 次</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn--primary btn--block" type="button" data-action="pay-distill">立即下单</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="distill-note">
|
||||||
|
<p>付款后请添加客服微信,我们将一对一指导你完成聊天记录导出与上传。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="distill-revenue">
|
||||||
|
<p>创作者可推广蒸馏前任服务获得分润,具体比例请在创作者管理中心配置。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- P5: About View -->
|
||||||
|
<section id="about" class="view view--about">
|
||||||
|
<header class="creator-header">
|
||||||
|
<button class="icon-btn" type="button" data-action="back" aria-label="返回">←</button>
|
||||||
|
<div class="creator-brand">
|
||||||
|
<span class="creator-brand__name">关于 Eternal AI</span>
|
||||||
|
<span class="creator-brand__step">安全 · 隐私 · 信任</span>
|
||||||
|
</div>
|
||||||
|
<div class="stepper" aria-hidden="true"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="about-page">
|
||||||
|
<div class="about-section">
|
||||||
|
<h3 class="about-section__title">平台简介</h3>
|
||||||
|
<p class="about-section__text">Eternal AI 是一个 AI 陪伴平台,帮助创作者将人设设定部署为可对话的 AI 角色,让用户在微信中与 ta 交流。我们致力于在记忆与陪伴中,遇见更懂你的 AI。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about-section">
|
||||||
|
<h3 class="about-section__title">连接方式说明</h3>
|
||||||
|
<p class="about-section__text">Eternal AI 基于微信 Claw 开放协议(iLink),非破解、非抓包,在微信官方授权范围内实现 AI 联系人接入。你的微信账号安全不受影响。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about-section">
|
||||||
|
<h3 class="about-section__title">安全类</h3>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-q" type="button">你们能看到我其他的微信聊天吗?<span class="faq-icon">+</span></button>
|
||||||
|
<div class="faq-a"><p>不能。我们只能看到你与 AI 角色的对话,无法访问你的其他聊天、朋友圈或通讯录。</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-q" type="button">会不会偷偷读我的朋友圈和通讯录?<span class="faq-icon">+</span></button>
|
||||||
|
<div class="faq-a"><p>不会。我们仅通过 Claw 协议与 AI 联系人通信,不具备读取你朋友圈或通讯录的能力。</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about-section">
|
||||||
|
<h3 class="about-section__title">隐私类</h3>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-q" type="button">我和 AI 说的话会被保存吗?<span class="faq-icon">+</span></button>
|
||||||
|
<div class="faq-a"><p>对话内容会保存在你的专属记忆库中,用于 AI 角色记住你们的对话上下文。仅你和你的 AI 角色可访问,我们不会人工查看。</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-q" type="button">蒸馏前任要上传聊天记录,这些数据安全吗?<span class="faq-icon">+</span></button>
|
||||||
|
<div class="faq-a"><p>上传的聊天记录仅用于训练你的专属 AI 前任,处理完成后原始数据将被删除。我们承诺不会将你的数据用于其他用途。</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about-section">
|
||||||
|
<h3 class="about-section__title">账号类</h3>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-q" type="button">用了会不会封我的微信号?<span class="faq-icon">+</span></button>
|
||||||
|
<div class="faq-a"><p>不会。Claw 协议在微信官方授权范围内运行,与正常添加联系人无异,不存在封号风险。</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-q" type="button">扫码之后对方能控制我的微信吗?<span class="faq-icon">+</span></button>
|
||||||
|
<div class="faq-a"><p>不能。AI 角色只是一个普通的微信联系人,只能与你收发消息,无法控制你的账号。</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about-section">
|
||||||
|
<h3 class="about-section__title">情感类</h3>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-q" type="button">我聊的内容会有人看吗?<span class="faq-icon">+</span></button>
|
||||||
|
<div class="faq-a"><p>不会有人工查看你的对话内容。除非你主动联系客服反馈问题,我们才会根据你的授权查看相关记录。</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-q" type="button">你们会把我的对话内容拿去训练 AI 吗?<span class="faq-icon">+</span></button>
|
||||||
|
<div class="faq-a"><p>不会。你的对话内容仅用于你自己的 AI 角色的记忆,绝不会用于训练其他模型或分享给第三方。</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- P6: Creator Onboarding View -->
|
||||||
|
<section id="onboarding" class="view view--onboarding">
|
||||||
|
<header class="creator-header">
|
||||||
|
<button class="icon-btn" type="button" data-action="back" aria-label="返回">←</button>
|
||||||
|
<div class="creator-brand">
|
||||||
|
<span class="creator-brand__name">创作者入驻</span>
|
||||||
|
<span class="creator-brand__step">部署 AI 角色,私域变现</span>
|
||||||
|
</div>
|
||||||
|
<div class="stepper" aria-hidden="true"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="onboarding-page">
|
||||||
|
<div class="onboarding-section">
|
||||||
|
<h3 class="onboarding-section__title">平台能为创作者提供什么</h3>
|
||||||
|
<ul class="onboarding-list">
|
||||||
|
<li>部署 AI 角色:提交人设设定,平台负责技术部署</li>
|
||||||
|
<li>资金中转:平台处理用户付款,按比例结算给创作者</li>
|
||||||
|
<li>私域变现:通过专属链接绑定粉丝,持续获得订阅收入</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="onboarding-section">
|
||||||
|
<h3 class="onboarding-section__title">合作模式</h3>
|
||||||
|
<p class="onboarding-section__text">创作者提交角色人设,平台部署上线。用户付款后,平台抽佣 20%,80% 转给创作者。结算周期为月结。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="onboarding-section onboarding-contact">
|
||||||
|
<h3 class="onboarding-section__title">联系入驻</h3>
|
||||||
|
<div class="onboarding-qr" id="onboarding-qr"></div>
|
||||||
|
<p class="onboarding-wechat">微信扫码添加负责人</p>
|
||||||
|
<p class="onboarding-wechat-id">微信号:EternalAI_Official</p>
|
||||||
|
<p class="onboarding-note">添加时请备注「创作者入驻」,我们会一对一沟通合作细节。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- P7: Creator Center View -->
|
||||||
|
<section id="creator-center" class="view view--creator-center">
|
||||||
|
<header class="creator-header">
|
||||||
|
<button class="icon-btn" type="button" data-action="back" aria-label="返回">←</button>
|
||||||
|
<div class="creator-brand">
|
||||||
|
<span class="creator-brand__name">创作者管理中心</span>
|
||||||
|
<span class="creator-brand__step" id="center-tab-label">我的角色</span>
|
||||||
|
</div>
|
||||||
|
<div class="stepper" aria-hidden="true"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="center-tabs" role="tablist" aria-label="创作者管理中心">
|
||||||
|
<button class="center-tab active" type="button" role="tab" aria-selected="true" data-center-tab="roles">我的角色</button>
|
||||||
|
<button class="center-tab" type="button" role="tab" aria-selected="false" data-center-tab="income">收入</button>
|
||||||
|
<button class="center-tab" type="button" role="tab" aria-selected="false" data-center-tab="settings">我的</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Roles -->
|
||||||
|
<div class="center-panel active" id="center-roles">
|
||||||
|
<div class="role-list" id="creator-role-list" aria-live="polite" aria-label="我的角色列表"></div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn--primary btn--block" type="button" data-action="new-role">+ 新建角色</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Income -->
|
||||||
|
<div class="center-panel" id="center-income">
|
||||||
|
<div class="income-balance">
|
||||||
|
<span class="income-balance__label">可提现余额</span>
|
||||||
|
<span class="income-balance__value" id="income-balance">¥ 0.00</span>
|
||||||
|
</div>
|
||||||
|
<div class="income-section">
|
||||||
|
<h3 class="income-section__title">流水明细</h3>
|
||||||
|
<div class="income-list" id="income-list" aria-live="polite" aria-label="收入流水">
|
||||||
|
<p class="income-empty">暂无流水记录</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="income-section">
|
||||||
|
<h3 class="income-section__title">申请提现</h3>
|
||||||
|
<form id="withdraw-form" class="withdraw-form" autocomplete="off">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">收款方式</span>
|
||||||
|
<select class="field__input" name="method" required>
|
||||||
|
<option value="wechat">微信</option>
|
||||||
|
<option value="alipay">支付宝</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">提现金额(¥)</span>
|
||||||
|
<input class="field__input" name="amount" type="number" min="1" step="0.01" placeholder="请输入金额" required />
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn--primary btn--block" type="submit">提交提现申请</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Settings -->
|
||||||
|
<div class="center-panel" id="center-settings">
|
||||||
|
<form id="settings-form" class="settings-form" autocomplete="off">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">创作者名字</span>
|
||||||
|
<input class="field__input" name="creatorName" type="text" placeholder="你的笔名" id="settings-name" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">角色库名称(首页「我的 XXX」显示的文字)</span>
|
||||||
|
<input class="field__input" name="libraryName" type="text" placeholder="如:云朵的后宫" id="settings-library" />
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn--primary btn--block" type="submit">保存设置</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- API Key 管理 -->
|
||||||
|
<div class="api-key-section" style="margin-top: 2rem;">
|
||||||
|
<h3 class="settings-section-title">API Key 管理</h3>
|
||||||
|
<p class="settings-section-desc" style="color: var(--text-muted); font-size: 0.875rem; margin-bottom: 1rem;">
|
||||||
|
用于在 Hermes 机器上通过 curl 拉取角色配置文件
|
||||||
|
</p>
|
||||||
|
<div class="form-actions" style="margin-bottom: 1rem;">
|
||||||
|
<button class="btn btn--outline btn--block" type="button" data-action="generate-apikey">生成新 API Key</button>
|
||||||
|
</div>
|
||||||
|
<div id="apikey-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn--ghost btn--block" type="button" data-action="logout">退出登录</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Creator Role Edit View (formerly character creator) -->
|
||||||
|
<section id="creator" class="view view--creator">
|
||||||
|
<header class="creator-header">
|
||||||
|
<button class="icon-btn" type="button" data-action="back-to-center" aria-label="返回">←</button>
|
||||||
|
<div class="creator-brand">
|
||||||
|
<span class="creator-brand__name">角色编辑</span>
|
||||||
|
<span class="creator-brand__step">生成配置文件</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stepper" aria-hidden="true">
|
<div class="stepper" aria-hidden="true">
|
||||||
<span class="stepper__dot active"></span>
|
<span class="stepper__dot active"></span>
|
||||||
|
|
@ -158,7 +522,7 @@
|
||||||
</label>
|
</label>
|
||||||
<label class="field field--half">
|
<label class="field field--half">
|
||||||
<span class="field__label">Temperature</span>
|
<span class="field__label">Temperature</span>
|
||||||
<input class="field__input" name="temperature" type="number" step="0.1" min="0" max="2" value="0.85" />
|
<input class="field__input" name="temperature" type="number" step="0.05" min="0" max="2" value="0.85" />
|
||||||
</label>
|
</label>
|
||||||
<label class="field field--half">
|
<label class="field field--half">
|
||||||
<span class="field__label">Max Tokens</span>
|
<span class="field__label">Max Tokens</span>
|
||||||
|
|
@ -211,134 +575,39 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="result-panel__preview">
|
<div class="result-panel__preview">
|
||||||
<div class="preview-tabs">
|
<div class="preview-tabs" role="tablist" aria-label="配置文件预览">
|
||||||
<button class="preview-tab active" type="button" data-tab="soul">Soul.md</button>
|
<button class="preview-tab active" type="button" role="tab" aria-selected="true" data-tab="soul">Soul.md</button>
|
||||||
<button class="preview-tab" type="button" data-tab="config">config.yaml</button>
|
<button class="preview-tab" type="button" role="tab" aria-selected="false" data-tab="config">config.yaml</button>
|
||||||
</div>
|
</div>
|
||||||
<pre class="preview-code" id="preview-code"><code></code></pre>
|
<pre class="preview-code" id="preview-code"><code></code></pre>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn btn--ghost" type="button" data-action="reset">再创建一个</button>
|
<button class="btn btn--ghost" type="button" data-action="reset">再创建一个</button>
|
||||||
<button class="btn btn--primary" type="button" data-action="back">返回首页</button>
|
<button class="btn btn--primary" type="button" data-action="back-to-center">返回管理中心</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Auth View (Login / Register) -->
|
|
||||||
<section id="auth" class="view view--auth">
|
|
||||||
<header class="creator-header">
|
|
||||||
<button class="icon-btn" type="button" data-action="back" aria-label="返回">←</button>
|
|
||||||
<div class="creator-brand">
|
|
||||||
<span class="creator-brand__name">Eternal AI</span>
|
|
||||||
<span class="creator-brand__step">登录 / 注册</span>
|
|
||||||
</div>
|
|
||||||
<div class="stepper" aria-hidden="true"></div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="auth-tabs">
|
|
||||||
<button class="auth-tab active" type="button" data-tab="login">登录</button>
|
|
||||||
<button class="auth-tab" type="button" data-tab="register">注册</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="login-form" class="auth-form active" autocomplete="off" data-form="login">
|
|
||||||
<div class="field-group">
|
|
||||||
<label class="field">
|
|
||||||
<span class="field__label">手机号 / 用户名</span>
|
|
||||||
<input class="field__input" name="account" type="text" placeholder="请输入手机号或用户名" required />
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span class="field__label">密码</span>
|
|
||||||
<input class="field__input" name="password" type="password" placeholder="请输入密码" required />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-actions">
|
|
||||||
<button class="btn btn--primary btn--block" type="submit">登录</button>
|
|
||||||
</div>
|
|
||||||
<p class="auth-hint">登录后可查看角色库、管理已订阅的角色。</p>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form id="register-form" class="auth-form" autocomplete="off" data-form="register">
|
|
||||||
<div class="field-group">
|
|
||||||
<label class="field">
|
|
||||||
<span class="field__label">手机号 / 用户名</span>
|
|
||||||
<input class="field__input" name="account" type="text" placeholder="设置登录账号" required />
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span class="field__label">密码</span>
|
|
||||||
<input class="field__input" name="password" type="password" placeholder="设置密码" required minlength="6" />
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span class="field__label">确认密码</span>
|
|
||||||
<input class="field__input" name="confirmPassword" type="password" placeholder="再次输入密码" required />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-actions">
|
|
||||||
<button class="btn btn--primary btn--block" type="submit">注册</button>
|
|
||||||
</div>
|
|
||||||
<p class="auth-hint">注册即代表同意《用户协议》和《隐私政策》。</p>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Distill Ex View -->
|
|
||||||
<section id="distill" class="view view--distill-page">
|
|
||||||
<header class="creator-header">
|
|
||||||
<button class="icon-btn" type="button" data-action="back" aria-label="返回">←</button>
|
|
||||||
<div class="creator-brand">
|
|
||||||
<span class="creator-brand__name">Eternal AI</span>
|
|
||||||
<span class="creator-brand__step">蒸馏前任</span>
|
|
||||||
</div>
|
|
||||||
<div class="stepper" aria-hidden="true"></div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="distill-page">
|
|
||||||
<a href="#contact" class="distill-quick">没耐心?直接加微信定制沟通 →</a>
|
|
||||||
|
|
||||||
<div class="distill-section">
|
|
||||||
<h3 class="distill-section__title">什么是蒸馏前任?</h3>
|
|
||||||
<p class="distill-section__text">把你们曾经的聊天记录、语音习惯、相处细节交给我们,技术团队会将其蒸馏成一个可对话的 AI 前任。ta 会记得你们的暗号、说话节奏,甚至那些只有你们懂的小情绪。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="distill-section">
|
|
||||||
<h3 class="distill-section__title">适合谁?</h3>
|
|
||||||
<ul class="distill-list">
|
|
||||||
<li>想好好告别,却还没说完话的人</li>
|
|
||||||
<li>希望把一段关系以安全方式封存的人</li>
|
|
||||||
<li>想借 AI 完成自我疗愈、练习表达的人</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="distill-section">
|
|
||||||
<h3 class="distill-section__title">服务流程</h3>
|
|
||||||
<ol class="distill-steps">
|
|
||||||
<li><span>①</span> 下单付款</li>
|
|
||||||
<li><span>②</span> 客服指导导出聊天记录</li>
|
|
||||||
<li><span>③</span> 上传至指定云盘</li>
|
|
||||||
<li><span>④</span> 技术人员处理蒸馏</li>
|
|
||||||
<li><span>⑤</span> 完成后获得专属二维码和头像</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="distill-price">
|
|
||||||
<span class="distill-price__label">标准版</span>
|
|
||||||
<span class="distill-price__value">¥ 199</span>
|
|
||||||
<span class="distill-price__unit">/ 次</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button class="btn btn--primary btn--block" type="button">立即下单</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="distill-note">
|
|
||||||
<p>付款后请添加客服微信,我们将一对一指导你完成聊天记录导出与上传。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="distill-revenue">
|
|
||||||
<p>创作者可推广蒸馏前任服务获得分润,具体比例请在创作者管理中心配置。</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Tab Bar (U8) -->
|
||||||
|
<nav class="tab-bar" id="tab-bar" role="tablist" aria-label="主导航">
|
||||||
|
<button class="tab-bar__item active" type="button" role="tab" aria-selected="true" data-tab-action="tab-home">
|
||||||
|
<span class="tab-bar__icon" aria-hidden="true">⌂</span>
|
||||||
|
<span class="tab-bar__label">首页</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab-bar__item" type="button" role="tab" aria-selected="false" data-tab-action="tab-distill">
|
||||||
|
<span class="tab-bar__icon" aria-hidden="true">♡</span>
|
||||||
|
<span class="tab-bar__label">蒸馏前任</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab-bar__item" type="button" role="tab" aria-selected="false" data-tab-action="tab-mine">
|
||||||
|
<span class="tab-bar__icon" aria-hidden="true">☺</span>
|
||||||
|
<span class="tab-bar__label" id="tab-mine-label">我的</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Screen reader live region for view change announcements -->
|
||||||
|
<div id="sr-announce" class="visually-hidden" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,849 +0,0 @@
|
||||||
{
|
|
||||||
"name": "eternalai",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"node_modules/accepts": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-types": "^3.0.0",
|
|
||||||
"negotiator": "^1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/body-parser": {
|
|
||||||
"version": "2.3.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bytes": "^3.1.2",
|
|
||||||
"content-type": "^2.0.0",
|
|
||||||
"debug": "^4.4.3",
|
|
||||||
"http-errors": "^2.0.1",
|
|
||||||
"iconv-lite": "^0.7.2",
|
|
||||||
"on-finished": "^2.4.1",
|
|
||||||
"qs": "^6.15.2",
|
|
||||||
"raw-body": "^3.0.2",
|
|
||||||
"type-is": "^2.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/body-parser/node_modules/content-type": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/content-type/-/content-type-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bytes": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/call-bind-apply-helpers": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"function-bind": "^1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/call-bound": {
|
|
||||||
"version": "1.0.4",
|
|
||||||
"resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz",
|
|
||||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
|
||||||
"get-intrinsic": "^1.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/content-disposition": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/content-type": {
|
|
||||||
"version": "1.0.5",
|
|
||||||
"resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz",
|
|
||||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cookie": {
|
|
||||||
"version": "0.7.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz",
|
|
||||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cookie-signature": {
|
|
||||||
"version": "1.2.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
|
||||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.6.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/debug": {
|
|
||||||
"version": "4.4.3",
|
|
||||||
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
|
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/depd": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dunder-proto": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"gopd": "^1.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ee-first": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/encodeurl": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/es-define-property": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/es-errors": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/es-object-atoms": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/escape-html": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/etag": {
|
|
||||||
"version": "1.8.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz",
|
|
||||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/express": {
|
|
||||||
"version": "5.2.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/express/-/express-5.2.1.tgz",
|
|
||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"accepts": "^2.0.0",
|
|
||||||
"body-parser": "^2.2.1",
|
|
||||||
"content-disposition": "^1.0.0",
|
|
||||||
"content-type": "^1.0.5",
|
|
||||||
"cookie": "^0.7.1",
|
|
||||||
"cookie-signature": "^1.2.1",
|
|
||||||
"debug": "^4.4.0",
|
|
||||||
"depd": "^2.0.0",
|
|
||||||
"encodeurl": "^2.0.0",
|
|
||||||
"escape-html": "^1.0.3",
|
|
||||||
"etag": "^1.8.1",
|
|
||||||
"finalhandler": "^2.1.0",
|
|
||||||
"fresh": "^2.0.0",
|
|
||||||
"http-errors": "^2.0.0",
|
|
||||||
"merge-descriptors": "^2.0.0",
|
|
||||||
"mime-types": "^3.0.0",
|
|
||||||
"on-finished": "^2.4.1",
|
|
||||||
"once": "^1.4.0",
|
|
||||||
"parseurl": "^1.3.3",
|
|
||||||
"proxy-addr": "^2.0.7",
|
|
||||||
"qs": "^6.14.0",
|
|
||||||
"range-parser": "^1.2.1",
|
|
||||||
"router": "^2.2.0",
|
|
||||||
"send": "^1.1.0",
|
|
||||||
"serve-static": "^2.2.0",
|
|
||||||
"statuses": "^2.0.1",
|
|
||||||
"type-is": "^2.0.1",
|
|
||||||
"vary": "^1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/finalhandler": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "^4.4.0",
|
|
||||||
"encodeurl": "^2.0.0",
|
|
||||||
"escape-html": "^1.0.3",
|
|
||||||
"on-finished": "^2.4.1",
|
|
||||||
"parseurl": "^1.3.3",
|
|
||||||
"statuses": "^2.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/forwarded": {
|
|
||||||
"version": "0.2.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz",
|
|
||||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fresh": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/function-bind": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/get-intrinsic": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
|
||||||
"es-define-property": "^1.0.1",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"es-object-atoms": "^1.1.1",
|
|
||||||
"function-bind": "^1.1.2",
|
|
||||||
"get-proto": "^1.0.1",
|
|
||||||
"gopd": "^1.2.0",
|
|
||||||
"has-symbols": "^1.1.0",
|
|
||||||
"hasown": "^2.0.2",
|
|
||||||
"math-intrinsics": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/get-proto": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"dunder-proto": "^1.0.1",
|
|
||||||
"es-object-atoms": "^1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/gopd": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/has-symbols": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/hasown": {
|
|
||||||
"version": "2.0.4",
|
|
||||||
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz",
|
|
||||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"function-bind": "^1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/http-errors": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"depd": "~2.0.0",
|
|
||||||
"inherits": "~2.0.4",
|
|
||||||
"setprototypeof": "~1.2.0",
|
|
||||||
"statuses": "~2.0.2",
|
|
||||||
"toidentifier": "~1.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/iconv-lite": {
|
|
||||||
"version": "0.7.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
|
||||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/inherits": {
|
|
||||||
"version": "2.0.4",
|
|
||||||
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
|
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/ipaddr.js": {
|
|
||||||
"version": "1.9.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
|
||||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-promise": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/math-intrinsics": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/media-typer": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/merge-descriptors": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mime-db": {
|
|
||||||
"version": "1.54.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz",
|
|
||||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mime-types": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-db": "^1.54.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/negotiator": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/object-inspect": {
|
|
||||||
"version": "1.13.4",
|
|
||||||
"resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",
|
|
||||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/on-finished": {
|
|
||||||
"version": "2.4.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
|
|
||||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ee-first": "1.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/once": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"wrappy": "1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/parseurl": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/path-to-regexp": {
|
|
||||||
"version": "8.4.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
|
||||||
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/proxy-addr": {
|
|
||||||
"version": "2.0.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
|
||||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"forwarded": "0.2.0",
|
|
||||||
"ipaddr.js": "1.9.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/qs": {
|
|
||||||
"version": "6.15.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.2.tgz",
|
|
||||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"side-channel": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/range-parser": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/raw-body": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bytes": "~3.1.2",
|
|
||||||
"http-errors": "~2.0.1",
|
|
||||||
"iconv-lite": "~0.7.0",
|
|
||||||
"unpipe": "~1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/router": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/router/-/router-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "^4.4.0",
|
|
||||||
"depd": "^2.0.0",
|
|
||||||
"is-promise": "^4.0.0",
|
|
||||||
"parseurl": "^1.3.3",
|
|
||||||
"path-to-regexp": "^8.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/safer-buffer": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/send": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/send/-/send-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "^4.4.3",
|
|
||||||
"encodeurl": "^2.0.0",
|
|
||||||
"escape-html": "^1.0.3",
|
|
||||||
"etag": "^1.8.1",
|
|
||||||
"fresh": "^2.0.0",
|
|
||||||
"http-errors": "^2.0.1",
|
|
||||||
"mime-types": "^3.0.2",
|
|
||||||
"ms": "^2.1.3",
|
|
||||||
"on-finished": "^2.4.1",
|
|
||||||
"range-parser": "^1.2.1",
|
|
||||||
"statuses": "^2.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/serve-static": {
|
|
||||||
"version": "2.2.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-2.2.1.tgz",
|
|
||||||
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"encodeurl": "^2.0.0",
|
|
||||||
"escape-html": "^1.0.3",
|
|
||||||
"parseurl": "^1.3.3",
|
|
||||||
"send": "^1.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/setprototypeof": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/side-channel": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"object-inspect": "^1.13.3",
|
|
||||||
"side-channel-list": "^1.0.0",
|
|
||||||
"side-channel-map": "^1.0.1",
|
|
||||||
"side-channel-weakmap": "^1.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/side-channel-list": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"object-inspect": "^1.13.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/side-channel-map": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bound": "^1.0.2",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"get-intrinsic": "^1.2.5",
|
|
||||||
"object-inspect": "^1.13.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/side-channel-weakmap": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bound": "^1.0.2",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"get-intrinsic": "^1.2.5",
|
|
||||||
"object-inspect": "^1.13.3",
|
|
||||||
"side-channel-map": "^1.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/statuses": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/toidentifier": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/type-is": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"content-type": "^2.0.0",
|
|
||||||
"media-typer": "^1.1.0",
|
|
||||||
"mime-types": "^3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/type-is/node_modules/content-type": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/content-type/-/content-type-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/unpipe": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vary": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/wrappy": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
|
||||||
"license": "ISC"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,250 +0,0 @@
|
||||||
2.0.0 / 2024-08-31
|
|
||||||
==================
|
|
||||||
|
|
||||||
* Drop node <18 support
|
|
||||||
* deps: mime-types@^3.0.0
|
|
||||||
* deps: negotiator@^1.0.0
|
|
||||||
|
|
||||||
1.3.8 / 2022-02-02
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.34
|
|
||||||
- deps: mime-db@~1.51.0
|
|
||||||
* deps: negotiator@0.6.3
|
|
||||||
|
|
||||||
1.3.7 / 2019-04-29
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: negotiator@0.6.2
|
|
||||||
- Fix sorting charset, encoding, and language with extra parameters
|
|
||||||
|
|
||||||
1.3.6 / 2019-04-28
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.24
|
|
||||||
- deps: mime-db@~1.40.0
|
|
||||||
|
|
||||||
1.3.5 / 2018-02-28
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.18
|
|
||||||
- deps: mime-db@~1.33.0
|
|
||||||
|
|
||||||
1.3.4 / 2017-08-22
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.16
|
|
||||||
- deps: mime-db@~1.29.0
|
|
||||||
|
|
||||||
1.3.3 / 2016-05-02
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.11
|
|
||||||
- deps: mime-db@~1.23.0
|
|
||||||
* deps: negotiator@0.6.1
|
|
||||||
- perf: improve `Accept` parsing speed
|
|
||||||
- perf: improve `Accept-Charset` parsing speed
|
|
||||||
- perf: improve `Accept-Encoding` parsing speed
|
|
||||||
- perf: improve `Accept-Language` parsing speed
|
|
||||||
|
|
||||||
1.3.2 / 2016-03-08
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.10
|
|
||||||
- Fix extension of `application/dash+xml`
|
|
||||||
- Update primary extension for `audio/mp4`
|
|
||||||
- deps: mime-db@~1.22.0
|
|
||||||
|
|
||||||
1.3.1 / 2016-01-19
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.9
|
|
||||||
- deps: mime-db@~1.21.0
|
|
||||||
|
|
||||||
1.3.0 / 2015-09-29
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.7
|
|
||||||
- deps: mime-db@~1.19.0
|
|
||||||
* deps: negotiator@0.6.0
|
|
||||||
- Fix including type extensions in parameters in `Accept` parsing
|
|
||||||
- Fix parsing `Accept` parameters with quoted equals
|
|
||||||
- Fix parsing `Accept` parameters with quoted semicolons
|
|
||||||
- Lazy-load modules from main entry point
|
|
||||||
- perf: delay type concatenation until needed
|
|
||||||
- perf: enable strict mode
|
|
||||||
- perf: hoist regular expressions
|
|
||||||
- perf: remove closures getting spec properties
|
|
||||||
- perf: remove a closure from media type parsing
|
|
||||||
- perf: remove property delete from media type parsing
|
|
||||||
|
|
||||||
1.2.13 / 2015-09-06
|
|
||||||
===================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.6
|
|
||||||
- deps: mime-db@~1.18.0
|
|
||||||
|
|
||||||
1.2.12 / 2015-07-30
|
|
||||||
===================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.4
|
|
||||||
- deps: mime-db@~1.16.0
|
|
||||||
|
|
||||||
1.2.11 / 2015-07-16
|
|
||||||
===================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.3
|
|
||||||
- deps: mime-db@~1.15.0
|
|
||||||
|
|
||||||
1.2.10 / 2015-07-01
|
|
||||||
===================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.2
|
|
||||||
- deps: mime-db@~1.14.0
|
|
||||||
|
|
||||||
1.2.9 / 2015-06-08
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.1
|
|
||||||
- perf: fix deopt during mapping
|
|
||||||
|
|
||||||
1.2.8 / 2015-06-07
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.0
|
|
||||||
- deps: mime-db@~1.13.0
|
|
||||||
* perf: avoid argument reassignment & argument slice
|
|
||||||
* perf: avoid negotiator recursive construction
|
|
||||||
* perf: enable strict mode
|
|
||||||
* perf: remove unnecessary bitwise operator
|
|
||||||
|
|
||||||
1.2.7 / 2015-05-10
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: negotiator@0.5.3
|
|
||||||
- Fix media type parameter matching to be case-insensitive
|
|
||||||
|
|
||||||
1.2.6 / 2015-05-07
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.0.11
|
|
||||||
- deps: mime-db@~1.9.1
|
|
||||||
* deps: negotiator@0.5.2
|
|
||||||
- Fix comparing media types with quoted values
|
|
||||||
- Fix splitting media types with quoted commas
|
|
||||||
|
|
||||||
1.2.5 / 2015-03-13
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.0.10
|
|
||||||
- deps: mime-db@~1.8.0
|
|
||||||
|
|
||||||
1.2.4 / 2015-02-14
|
|
||||||
==================
|
|
||||||
|
|
||||||
* Support Node.js 0.6
|
|
||||||
* deps: mime-types@~2.0.9
|
|
||||||
- deps: mime-db@~1.7.0
|
|
||||||
* deps: negotiator@0.5.1
|
|
||||||
- Fix preference sorting to be stable for long acceptable lists
|
|
||||||
|
|
||||||
1.2.3 / 2015-01-31
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.0.8
|
|
||||||
- deps: mime-db@~1.6.0
|
|
||||||
|
|
||||||
1.2.2 / 2014-12-30
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.0.7
|
|
||||||
- deps: mime-db@~1.5.0
|
|
||||||
|
|
||||||
1.2.1 / 2014-12-30
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.0.5
|
|
||||||
- deps: mime-db@~1.3.1
|
|
||||||
|
|
||||||
1.2.0 / 2014-12-19
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: negotiator@0.5.0
|
|
||||||
- Fix list return order when large accepted list
|
|
||||||
- Fix missing identity encoding when q=0 exists
|
|
||||||
- Remove dynamic building of Negotiator class
|
|
||||||
|
|
||||||
1.1.4 / 2014-12-10
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.0.4
|
|
||||||
- deps: mime-db@~1.3.0
|
|
||||||
|
|
||||||
1.1.3 / 2014-11-09
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.0.3
|
|
||||||
- deps: mime-db@~1.2.0
|
|
||||||
|
|
||||||
1.1.2 / 2014-10-14
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: negotiator@0.4.9
|
|
||||||
- Fix error when media type has invalid parameter
|
|
||||||
|
|
||||||
1.1.1 / 2014-09-28
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.0.2
|
|
||||||
- deps: mime-db@~1.1.0
|
|
||||||
* deps: negotiator@0.4.8
|
|
||||||
- Fix all negotiations to be case-insensitive
|
|
||||||
- Stable sort preferences of same quality according to client order
|
|
||||||
|
|
||||||
1.1.0 / 2014-09-02
|
|
||||||
==================
|
|
||||||
|
|
||||||
* update `mime-types`
|
|
||||||
|
|
||||||
1.0.7 / 2014-07-04
|
|
||||||
==================
|
|
||||||
|
|
||||||
* Fix wrong type returned from `type` when match after unknown extension
|
|
||||||
|
|
||||||
1.0.6 / 2014-06-24
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: negotiator@0.4.7
|
|
||||||
|
|
||||||
1.0.5 / 2014-06-20
|
|
||||||
==================
|
|
||||||
|
|
||||||
* fix crash when unknown extension given
|
|
||||||
|
|
||||||
1.0.4 / 2014-06-19
|
|
||||||
==================
|
|
||||||
|
|
||||||
* use `mime-types`
|
|
||||||
|
|
||||||
1.0.3 / 2014-06-11
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: negotiator@0.4.6
|
|
||||||
- Order by specificity when quality is the same
|
|
||||||
|
|
||||||
1.0.2 / 2014-05-29
|
|
||||||
==================
|
|
||||||
|
|
||||||
* Fix interpretation when header not in request
|
|
||||||
* deps: pin negotiator@0.4.5
|
|
||||||
|
|
||||||
1.0.1 / 2014-01-18
|
|
||||||
==================
|
|
||||||
|
|
||||||
* Identity encoding isn't always acceptable
|
|
||||||
* deps: negotiator@~0.4.0
|
|
||||||
|
|
||||||
1.0.0 / 2013-12-27
|
|
||||||
==================
|
|
||||||
|
|
||||||
* Genesis
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
(The MIT License)
|
|
||||||
|
|
||||||
Copyright (c) 2014 Jonathan Ong <me@jongleberry.com>
|
|
||||||
Copyright (c) 2015 Douglas Christopher Wilson <doug@somethingdoug.com>
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of this software and associated documentation files (the
|
|
||||||
'Software'), to deal in the Software without restriction, including
|
|
||||||
without limitation the rights to use, copy, modify, merge, publish,
|
|
||||||
distribute, sublicense, and/or sell copies of the Software, and to
|
|
||||||
permit persons to whom the Software is furnished to do so, subject to
|
|
||||||
the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be
|
|
||||||
included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
||||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
||||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
||||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
||||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
# accepts
|
|
||||||
|
|
||||||
[![NPM Version][npm-version-image]][npm-url]
|
|
||||||
[![NPM Downloads][npm-downloads-image]][npm-url]
|
|
||||||
[![Node.js Version][node-version-image]][node-version-url]
|
|
||||||
[![Build Status][github-actions-ci-image]][github-actions-ci-url]
|
|
||||||
[![Test Coverage][coveralls-image]][coveralls-url]
|
|
||||||
|
|
||||||
Higher level content negotiation based on [negotiator](https://www.npmjs.com/package/negotiator).
|
|
||||||
Extracted from [koa](https://www.npmjs.com/package/koa) for general use.
|
|
||||||
|
|
||||||
In addition to negotiator, it allows:
|
|
||||||
|
|
||||||
- Allows types as an array or arguments list, ie `(['text/html', 'application/json'])`
|
|
||||||
as well as `('text/html', 'application/json')`.
|
|
||||||
- Allows type shorthands such as `json`.
|
|
||||||
- Returns `false` when no types match
|
|
||||||
- Treats non-existent headers as `*`
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
This is a [Node.js](https://nodejs.org/en/) module available through the
|
|
||||||
[npm registry](https://www.npmjs.com/). Installation is done using the
|
|
||||||
[`npm install` command](https://docs.npmjs.com/getting-started/installing-npm-packages-locally):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ npm install accepts
|
|
||||||
```
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
```js
|
|
||||||
var accepts = require('accepts')
|
|
||||||
```
|
|
||||||
|
|
||||||
### accepts(req)
|
|
||||||
|
|
||||||
Create a new `Accepts` object for the given `req`.
|
|
||||||
|
|
||||||
#### .charset(charsets)
|
|
||||||
|
|
||||||
Return the first accepted charset. If nothing in `charsets` is accepted,
|
|
||||||
then `false` is returned.
|
|
||||||
|
|
||||||
#### .charsets()
|
|
||||||
|
|
||||||
Return the charsets that the request accepts, in the order of the client's
|
|
||||||
preference (most preferred first).
|
|
||||||
|
|
||||||
#### .encoding(encodings)
|
|
||||||
|
|
||||||
Return the first accepted encoding. If nothing in `encodings` is accepted,
|
|
||||||
then `false` is returned.
|
|
||||||
|
|
||||||
#### .encodings()
|
|
||||||
|
|
||||||
Return the encodings that the request accepts, in the order of the client's
|
|
||||||
preference (most preferred first).
|
|
||||||
|
|
||||||
#### .language(languages)
|
|
||||||
|
|
||||||
Return the first accepted language. If nothing in `languages` is accepted,
|
|
||||||
then `false` is returned.
|
|
||||||
|
|
||||||
#### .languages()
|
|
||||||
|
|
||||||
Return the languages that the request accepts, in the order of the client's
|
|
||||||
preference (most preferred first).
|
|
||||||
|
|
||||||
#### .type(types)
|
|
||||||
|
|
||||||
Return the first accepted type (and it is returned as the same text as what
|
|
||||||
appears in the `types` array). If nothing in `types` is accepted, then `false`
|
|
||||||
is returned.
|
|
||||||
|
|
||||||
The `types` array can contain full MIME types or file extensions. Any value
|
|
||||||
that is not a full MIME type is passed to `require('mime-types').lookup`.
|
|
||||||
|
|
||||||
#### .types()
|
|
||||||
|
|
||||||
Return the types that the request accepts, in the order of the client's
|
|
||||||
preference (most preferred first).
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Simple type negotiation
|
|
||||||
|
|
||||||
This simple example shows how to use `accepts` to return a different typed
|
|
||||||
respond body based on what the client wants to accept. The server lists it's
|
|
||||||
preferences in order and will get back the best match between the client and
|
|
||||||
server.
|
|
||||||
|
|
||||||
```js
|
|
||||||
var accepts = require('accepts')
|
|
||||||
var http = require('http')
|
|
||||||
|
|
||||||
function app (req, res) {
|
|
||||||
var accept = accepts(req)
|
|
||||||
|
|
||||||
// the order of this list is significant; should be server preferred order
|
|
||||||
switch (accept.type(['json', 'html'])) {
|
|
||||||
case 'json':
|
|
||||||
res.setHeader('Content-Type', 'application/json')
|
|
||||||
res.write('{"hello":"world!"}')
|
|
||||||
break
|
|
||||||
case 'html':
|
|
||||||
res.setHeader('Content-Type', 'text/html')
|
|
||||||
res.write('<b>hello, world!</b>')
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
// the fallback is text/plain, so no need to specify it above
|
|
||||||
res.setHeader('Content-Type', 'text/plain')
|
|
||||||
res.write('hello, world!')
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
res.end()
|
|
||||||
}
|
|
||||||
|
|
||||||
http.createServer(app).listen(3000)
|
|
||||||
```
|
|
||||||
|
|
||||||
You can test this out with the cURL program:
|
|
||||||
```sh
|
|
||||||
curl -I -H'Accept: text/html' http://localhost:3000/
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[MIT](LICENSE)
|
|
||||||
|
|
||||||
[coveralls-image]: https://badgen.net/coveralls/c/github/jshttp/accepts/master
|
|
||||||
[coveralls-url]: https://coveralls.io/r/jshttp/accepts?branch=master
|
|
||||||
[github-actions-ci-image]: https://badgen.net/github/checks/jshttp/accepts/master?label=ci
|
|
||||||
[github-actions-ci-url]: https://github.com/jshttp/accepts/actions/workflows/ci.yml
|
|
||||||
[node-version-image]: https://badgen.net/npm/node/accepts
|
|
||||||
[node-version-url]: https://nodejs.org/en/download
|
|
||||||
[npm-downloads-image]: https://badgen.net/npm/dm/accepts
|
|
||||||
[npm-url]: https://npmjs.org/package/accepts
|
|
||||||
[npm-version-image]: https://badgen.net/npm/v/accepts
|
|
||||||
|
|
@ -1,238 +0,0 @@
|
||||||
/*!
|
|
||||||
* accepts
|
|
||||||
* Copyright(c) 2014 Jonathan Ong
|
|
||||||
* Copyright(c) 2015 Douglas Christopher Wilson
|
|
||||||
* MIT Licensed
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module dependencies.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
var Negotiator = require('negotiator')
|
|
||||||
var mime = require('mime-types')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module exports.
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
module.exports = Accepts
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new Accepts object for the given req.
|
|
||||||
*
|
|
||||||
* @param {object} req
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
function Accepts (req) {
|
|
||||||
if (!(this instanceof Accepts)) {
|
|
||||||
return new Accepts(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.headers = req.headers
|
|
||||||
this.negotiator = new Negotiator(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the given `type(s)` is acceptable, returning
|
|
||||||
* the best match when true, otherwise `undefined`, in which
|
|
||||||
* case you should respond with 406 "Not Acceptable".
|
|
||||||
*
|
|
||||||
* The `type` value may be a single mime type string
|
|
||||||
* such as "application/json", the extension name
|
|
||||||
* such as "json" or an array `["json", "html", "text/plain"]`. When a list
|
|
||||||
* or array is given the _best_ match, if any is returned.
|
|
||||||
*
|
|
||||||
* Examples:
|
|
||||||
*
|
|
||||||
* // Accept: text/html
|
|
||||||
* this.types('html');
|
|
||||||
* // => "html"
|
|
||||||
*
|
|
||||||
* // Accept: text/*, application/json
|
|
||||||
* this.types('html');
|
|
||||||
* // => "html"
|
|
||||||
* this.types('text/html');
|
|
||||||
* // => "text/html"
|
|
||||||
* this.types('json', 'text');
|
|
||||||
* // => "json"
|
|
||||||
* this.types('application/json');
|
|
||||||
* // => "application/json"
|
|
||||||
*
|
|
||||||
* // Accept: text/*, application/json
|
|
||||||
* this.types('image/png');
|
|
||||||
* this.types('png');
|
|
||||||
* // => undefined
|
|
||||||
*
|
|
||||||
* // Accept: text/*;q=.5, application/json
|
|
||||||
* this.types(['html', 'json']);
|
|
||||||
* this.types('html', 'json');
|
|
||||||
* // => "json"
|
|
||||||
*
|
|
||||||
* @param {String|Array} types...
|
|
||||||
* @return {String|Array|Boolean}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
Accepts.prototype.type =
|
|
||||||
Accepts.prototype.types = function (types_) {
|
|
||||||
var types = types_
|
|
||||||
|
|
||||||
// support flattened arguments
|
|
||||||
if (types && !Array.isArray(types)) {
|
|
||||||
types = new Array(arguments.length)
|
|
||||||
for (var i = 0; i < types.length; i++) {
|
|
||||||
types[i] = arguments[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// no types, return all requested types
|
|
||||||
if (!types || types.length === 0) {
|
|
||||||
return this.negotiator.mediaTypes()
|
|
||||||
}
|
|
||||||
|
|
||||||
// no accept header, return first given type
|
|
||||||
if (!this.headers.accept) {
|
|
||||||
return types[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
var mimes = types.map(extToMime)
|
|
||||||
var accepts = this.negotiator.mediaTypes(mimes.filter(validMime))
|
|
||||||
var first = accepts[0]
|
|
||||||
|
|
||||||
return first
|
|
||||||
? types[mimes.indexOf(first)]
|
|
||||||
: false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return accepted encodings or best fit based on `encodings`.
|
|
||||||
*
|
|
||||||
* Given `Accept-Encoding: gzip, deflate`
|
|
||||||
* an array sorted by quality is returned:
|
|
||||||
*
|
|
||||||
* ['gzip', 'deflate']
|
|
||||||
*
|
|
||||||
* @param {String|Array} encodings...
|
|
||||||
* @return {String|Array}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
Accepts.prototype.encoding =
|
|
||||||
Accepts.prototype.encodings = function (encodings_) {
|
|
||||||
var encodings = encodings_
|
|
||||||
|
|
||||||
// support flattened arguments
|
|
||||||
if (encodings && !Array.isArray(encodings)) {
|
|
||||||
encodings = new Array(arguments.length)
|
|
||||||
for (var i = 0; i < encodings.length; i++) {
|
|
||||||
encodings[i] = arguments[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// no encodings, return all requested encodings
|
|
||||||
if (!encodings || encodings.length === 0) {
|
|
||||||
return this.negotiator.encodings()
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.negotiator.encodings(encodings)[0] || false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return accepted charsets or best fit based on `charsets`.
|
|
||||||
*
|
|
||||||
* Given `Accept-Charset: utf-8, iso-8859-1;q=0.2, utf-7;q=0.5`
|
|
||||||
* an array sorted by quality is returned:
|
|
||||||
*
|
|
||||||
* ['utf-8', 'utf-7', 'iso-8859-1']
|
|
||||||
*
|
|
||||||
* @param {String|Array} charsets...
|
|
||||||
* @return {String|Array}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
Accepts.prototype.charset =
|
|
||||||
Accepts.prototype.charsets = function (charsets_) {
|
|
||||||
var charsets = charsets_
|
|
||||||
|
|
||||||
// support flattened arguments
|
|
||||||
if (charsets && !Array.isArray(charsets)) {
|
|
||||||
charsets = new Array(arguments.length)
|
|
||||||
for (var i = 0; i < charsets.length; i++) {
|
|
||||||
charsets[i] = arguments[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// no charsets, return all requested charsets
|
|
||||||
if (!charsets || charsets.length === 0) {
|
|
||||||
return this.negotiator.charsets()
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.negotiator.charsets(charsets)[0] || false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return accepted languages or best fit based on `langs`.
|
|
||||||
*
|
|
||||||
* Given `Accept-Language: en;q=0.8, es, pt`
|
|
||||||
* an array sorted by quality is returned:
|
|
||||||
*
|
|
||||||
* ['es', 'pt', 'en']
|
|
||||||
*
|
|
||||||
* @param {String|Array} langs...
|
|
||||||
* @return {Array|String}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
Accepts.prototype.lang =
|
|
||||||
Accepts.prototype.langs =
|
|
||||||
Accepts.prototype.language =
|
|
||||||
Accepts.prototype.languages = function (languages_) {
|
|
||||||
var languages = languages_
|
|
||||||
|
|
||||||
// support flattened arguments
|
|
||||||
if (languages && !Array.isArray(languages)) {
|
|
||||||
languages = new Array(arguments.length)
|
|
||||||
for (var i = 0; i < languages.length; i++) {
|
|
||||||
languages[i] = arguments[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// no languages, return all requested languages
|
|
||||||
if (!languages || languages.length === 0) {
|
|
||||||
return this.negotiator.languages()
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.negotiator.languages(languages)[0] || false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert extnames to mime.
|
|
||||||
*
|
|
||||||
* @param {String} type
|
|
||||||
* @return {String}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
function extToMime (type) {
|
|
||||||
return type.indexOf('/') === -1
|
|
||||||
? mime.lookup(type)
|
|
||||||
: type
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if mime is valid.
|
|
||||||
*
|
|
||||||
* @param {String} type
|
|
||||||
* @return {Boolean}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
function validMime (type) {
|
|
||||||
return typeof type === 'string'
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
{
|
|
||||||
"name": "accepts",
|
|
||||||
"description": "Higher-level content negotiation",
|
|
||||||
"version": "2.0.0",
|
|
||||||
"contributors": [
|
|
||||||
"Douglas Christopher Wilson <doug@somethingdoug.com>",
|
|
||||||
"Jonathan Ong <me@jongleberry.com> (http://jongleberry.com)"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"repository": "jshttp/accepts",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-types": "^3.0.0",
|
|
||||||
"negotiator": "^1.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"deep-equal": "1.0.1",
|
|
||||||
"eslint": "7.32.0",
|
|
||||||
"eslint-config-standard": "14.1.1",
|
|
||||||
"eslint-plugin-import": "2.25.4",
|
|
||||||
"eslint-plugin-markdown": "2.2.1",
|
|
||||||
"eslint-plugin-node": "11.1.0",
|
|
||||||
"eslint-plugin-promise": "4.3.1",
|
|
||||||
"eslint-plugin-standard": "4.1.0",
|
|
||||||
"mocha": "9.2.0",
|
|
||||||
"nyc": "15.1.0"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"LICENSE",
|
|
||||||
"HISTORY.md",
|
|
||||||
"index.js"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"lint": "eslint .",
|
|
||||||
"test": "mocha --reporter spec --check-leaks --bail test/",
|
|
||||||
"test-ci": "nyc --reporter=lcov --reporter=text npm test",
|
|
||||||
"test-cov": "nyc --reporter=html --reporter=text npm test"
|
|
||||||
},
|
|
||||||
"keywords": [
|
|
||||||
"content",
|
|
||||||
"negotiation",
|
|
||||||
"accept",
|
|
||||||
"accepts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
(The MIT License)
|
|
||||||
|
|
||||||
Copyright (c) 2014 Jonathan Ong <me@jongleberry.com>
|
|
||||||
Copyright (c) 2014-2015 Douglas Christopher Wilson <doug@somethingdoug.com>
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of this software and associated documentation files (the
|
|
||||||
'Software'), to deal in the Software without restriction, including
|
|
||||||
without limitation the rights to use, copy, modify, merge, publish,
|
|
||||||
distribute, sublicense, and/or sell copies of the Software, and to
|
|
||||||
permit persons to whom the Software is furnished to do so, subject to
|
|
||||||
the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be
|
|
||||||
included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
||||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
||||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
||||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
||||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
|
|
@ -1,509 +0,0 @@
|
||||||
# body-parser
|
|
||||||
|
|
||||||
[![NPM Version][npm-version-image]][npm-url]
|
|
||||||
[![NPM Downloads][npm-downloads-image]][npm-url]
|
|
||||||
[![Build Status][ci-image]][ci-url]
|
|
||||||
[![Test Coverage][coveralls-image]][coveralls-url]
|
|
||||||
[![OpenSSF Scorecard Badge][ossf-scorecard-badge]][ossf-scorecard-visualizer]
|
|
||||||
|
|
||||||
Node.js body parsing middleware.
|
|
||||||
|
|
||||||
Parse incoming request bodies in a middleware before your handlers, available
|
|
||||||
under the `req.body` property.
|
|
||||||
|
|
||||||
**Note** As `req.body`'s shape is based on user-controlled input, all
|
|
||||||
properties and values in this object are untrusted and should be validated
|
|
||||||
before trusting. For example, `req.body.foo.toString()` may fail in multiple
|
|
||||||
ways, for example the `foo` property may not be there or may not be a string,
|
|
||||||
and `toString` may not be a function and instead a string or other user input.
|
|
||||||
|
|
||||||
[Learn about the anatomy of an HTTP transaction in Node.js](https://nodejs.org/en/learn/http/anatomy-of-an-http-transaction).
|
|
||||||
|
|
||||||
_This does not handle multipart bodies_, due to their complex and typically
|
|
||||||
large nature. For multipart bodies, you may be interested in the following
|
|
||||||
modules:
|
|
||||||
|
|
||||||
* [busboy](https://www.npmjs.com/package/busboy#readme) and
|
|
||||||
[connect-busboy](https://www.npmjs.com/package/connect-busboy#readme)
|
|
||||||
* [multiparty](https://www.npmjs.com/package/multiparty#readme) and
|
|
||||||
[connect-multiparty](https://www.npmjs.com/package/connect-multiparty#readme)
|
|
||||||
* [formidable](https://www.npmjs.com/package/formidable#readme)
|
|
||||||
* [multer](https://www.npmjs.com/package/multer#readme)
|
|
||||||
|
|
||||||
This module provides the following parsers:
|
|
||||||
|
|
||||||
* [JSON body parser](#bodyparserjsonoptions)
|
|
||||||
* [Raw body parser](#bodyparserrawoptions)
|
|
||||||
* [Text body parser](#bodyparsertextoptions)
|
|
||||||
* [URL-encoded form body parser](#bodyparserurlencodedoptions)
|
|
||||||
|
|
||||||
Other body parsers you might be interested in:
|
|
||||||
|
|
||||||
- [body](https://www.npmjs.com/package/body#readme)
|
|
||||||
- [co-body](https://www.npmjs.com/package/co-body#readme)
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ npm install body-parser
|
|
||||||
```
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
```js
|
|
||||||
// Import all parsers
|
|
||||||
const bodyParser = require('body-parser')
|
|
||||||
|
|
||||||
// Or import individual parsers directly
|
|
||||||
const json = require('body-parser/json')
|
|
||||||
const urlencoded = require('body-parser/urlencoded')
|
|
||||||
const raw = require('body-parser/raw')
|
|
||||||
const text = require('body-parser/text')
|
|
||||||
```
|
|
||||||
|
|
||||||
The `bodyParser` object exposes various factories to create middlewares. All
|
|
||||||
middlewares will populate the `req.body` property with the parsed body when
|
|
||||||
the `Content-Type` request header matches the `type` option.
|
|
||||||
|
|
||||||
The various errors returned by this module are described in the
|
|
||||||
[errors section](#errors).
|
|
||||||
|
|
||||||
### bodyParser.json([options])
|
|
||||||
|
|
||||||
Returns middleware that only parses `json` and only looks at requests where
|
|
||||||
the `Content-Type` header matches the `type` option. This parser accepts any
|
|
||||||
Unicode encoding of the body and supports automatic inflation of `gzip`,
|
|
||||||
`br` (brotli) and `deflate` encodings.
|
|
||||||
|
|
||||||
A new `body` object containing the parsed data is populated on the `request`
|
|
||||||
object after the middleware (i.e. `req.body`).
|
|
||||||
|
|
||||||
#### Options
|
|
||||||
|
|
||||||
The `json` function takes an optional `options` object that may contain any of
|
|
||||||
the following keys:
|
|
||||||
|
|
||||||
##### defaultCharset
|
|
||||||
|
|
||||||
Specify the default character set for the json content if the charset is not
|
|
||||||
specified in the `Content-Type` header of the request. Defaults to `utf-8`.
|
|
||||||
|
|
||||||
##### inflate
|
|
||||||
|
|
||||||
When set to `true`, then deflated (compressed) bodies will be inflated; when
|
|
||||||
`false`, deflated bodies are rejected. Defaults to `true`.
|
|
||||||
|
|
||||||
##### limit
|
|
||||||
|
|
||||||
Controls the maximum request body size. If this is a number, then the value
|
|
||||||
specifies the number of bytes; if it is a string, the value is passed to the
|
|
||||||
[bytes](https://www.npmjs.com/package/bytes) library for parsing. Defaults
|
|
||||||
to `'100kb'`.
|
|
||||||
|
|
||||||
> It’s recommended not to configure a very high limit and to use the default value whenever possible. Allowing larger payloads increases memory usage because of the resources required for decoding and transformations, and it can also lead to longer response times as more data is processed. By ‘very high’, we mean values above the default, for example payloads of 5 MB or more can already start to introduce these risks. With the default limits, these issues do not occur.
|
|
||||||
|
|
||||||
##### reviver
|
|
||||||
|
|
||||||
The `reviver` option is passed directly to `JSON.parse` as the second
|
|
||||||
argument. You can find more information on this argument
|
|
||||||
[in the MDN documentation about JSON.parse](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse#the_reviver_parameter).
|
|
||||||
|
|
||||||
##### strict
|
|
||||||
|
|
||||||
When set to `true`, will only accept arrays and objects; when `false` will
|
|
||||||
accept anything `JSON.parse` accepts. Defaults to `true`.
|
|
||||||
|
|
||||||
##### type
|
|
||||||
|
|
||||||
The `type` option is used to determine what media type the middleware will
|
|
||||||
parse. This option can be a string, array of strings, or a function. If not a
|
|
||||||
function, `type` option is passed directly to the
|
|
||||||
[type-is](https://www.npmjs.com/package/type-is#readme) library and this can
|
|
||||||
be an extension name (like `json`), a mime type (like `application/json`), or
|
|
||||||
a mime type with a wildcard (like `*/*` or `*/json`). If a function, the `type`
|
|
||||||
option is called as `fn(req)` and the request is parsed if it returns a truthy
|
|
||||||
value. Defaults to `application/json`.
|
|
||||||
|
|
||||||
##### verify
|
|
||||||
|
|
||||||
The `verify` option, if supplied, is called as `verify(req, res, buf, encoding)`,
|
|
||||||
where `buf` is a `Buffer` of the raw request body and `encoding` is the
|
|
||||||
encoding of the request. The parsing can be aborted by throwing an error.
|
|
||||||
|
|
||||||
### bodyParser.raw([options])
|
|
||||||
|
|
||||||
Returns middleware that parses all bodies as a `Buffer` and only looks at
|
|
||||||
requests where the `Content-Type` header matches the `type` option. This
|
|
||||||
parser supports automatic inflation of `gzip`, `br` (brotli) and `deflate`
|
|
||||||
encodings.
|
|
||||||
|
|
||||||
A new `body` object containing the parsed data is populated on the `request`
|
|
||||||
object after the middleware (i.e. `req.body`). This will be a `Buffer` object
|
|
||||||
of the body.
|
|
||||||
|
|
||||||
#### Options
|
|
||||||
|
|
||||||
The `raw` function takes an optional `options` object that may contain any of
|
|
||||||
the following keys:
|
|
||||||
|
|
||||||
##### inflate
|
|
||||||
|
|
||||||
When set to `true`, then deflated (compressed) bodies will be inflated; when
|
|
||||||
`false`, deflated bodies are rejected. Defaults to `true`.
|
|
||||||
|
|
||||||
##### limit
|
|
||||||
|
|
||||||
Controls the maximum request body size. If this is a number, then the value
|
|
||||||
specifies the number of bytes; if it is a string, the value is passed to the
|
|
||||||
[bytes](https://www.npmjs.com/package/bytes) library for parsing. Defaults
|
|
||||||
to `'100kb'`.
|
|
||||||
|
|
||||||
> It’s recommended not to configure a very high limit and to use the default value whenever possible. Allowing larger payloads increases memory usage because of the resources required for decoding and transformations, and it can also lead to longer response times as more data is processed. By ‘very high’, we mean values above the default, for example payloads of 5 MB or more can already start to introduce these risks. With the default limits, these issues do not occur.
|
|
||||||
|
|
||||||
##### type
|
|
||||||
|
|
||||||
The `type` option is used to determine what media type the middleware will
|
|
||||||
parse. This option can be a string, array of strings, or a function.
|
|
||||||
If not a function, `type` option is passed directly to the
|
|
||||||
[type-is](https://www.npmjs.com/package/type-is#readme) library and this
|
|
||||||
can be an extension name (like `bin`), a mime type (like
|
|
||||||
`application/octet-stream`), or a mime type with a wildcard (like `*/*` or
|
|
||||||
`application/*`). If a function, the `type` option is called as `fn(req)`
|
|
||||||
and the request is parsed if it returns a truthy value. Defaults to
|
|
||||||
`application/octet-stream`.
|
|
||||||
|
|
||||||
##### verify
|
|
||||||
|
|
||||||
The `verify` option, if supplied, is called as `verify(req, res, buf, encoding)`,
|
|
||||||
where `buf` is a `Buffer` of the raw request body and `encoding` is the
|
|
||||||
encoding of the request. The parsing can be aborted by throwing an error.
|
|
||||||
|
|
||||||
### bodyParser.text([options])
|
|
||||||
|
|
||||||
Returns middleware that parses all bodies as a string and only looks at
|
|
||||||
requests where the `Content-Type` header matches the `type` option. This
|
|
||||||
parser supports automatic inflation of `gzip`, `br` (brotli) and `deflate`
|
|
||||||
encodings.
|
|
||||||
|
|
||||||
A new `body` string containing the parsed data is populated on the `request`
|
|
||||||
object after the middleware (i.e. `req.body`). This will be a string of the
|
|
||||||
body.
|
|
||||||
|
|
||||||
#### Options
|
|
||||||
|
|
||||||
The `text` function takes an optional `options` object that may contain any of
|
|
||||||
the following keys:
|
|
||||||
|
|
||||||
##### defaultCharset
|
|
||||||
|
|
||||||
Specify the default character set for the text content if the charset is not
|
|
||||||
specified in the `Content-Type` header of the request. Defaults to `utf-8`.
|
|
||||||
|
|
||||||
##### inflate
|
|
||||||
|
|
||||||
When set to `true`, then deflated (compressed) bodies will be inflated; when
|
|
||||||
`false`, deflated bodies are rejected. Defaults to `true`.
|
|
||||||
|
|
||||||
##### limit
|
|
||||||
|
|
||||||
Controls the maximum request body size. If this is a number, then the value
|
|
||||||
specifies the number of bytes; if it is a string, the value is passed to the
|
|
||||||
[bytes](https://www.npmjs.com/package/bytes) library for parsing. Defaults
|
|
||||||
to `'100kb'`.
|
|
||||||
|
|
||||||
> It’s recommended not to configure a very high limit and to use the default value whenever possible. Allowing larger payloads increases memory usage because of the resources required for decoding and transformations, and it can also lead to longer response times as more data is processed. By ‘very high’, we mean values above the default, for example payloads of 5 MB or more can already start to introduce these risks. With the default limits, these issues do not occur.
|
|
||||||
|
|
||||||
##### type
|
|
||||||
|
|
||||||
The `type` option is used to determine what media type the middleware will
|
|
||||||
parse. This option can be a string, array of strings, or a function. If not
|
|
||||||
a function, `type` option is passed directly to the
|
|
||||||
[type-is](https://www.npmjs.com/package/type-is#readme) library and this can
|
|
||||||
be an extension name (like `txt`), a mime type (like `text/plain`), or a mime
|
|
||||||
type with a wildcard (like `*/*` or `text/*`). If a function, the `type`
|
|
||||||
option is called as `fn(req)` and the request is parsed if it returns a
|
|
||||||
truthy value. Defaults to `text/plain`.
|
|
||||||
|
|
||||||
##### verify
|
|
||||||
|
|
||||||
The `verify` option, if supplied, is called as `verify(req, res, buf, encoding)`,
|
|
||||||
where `buf` is a `Buffer` of the raw request body and `encoding` is the
|
|
||||||
encoding of the request. The parsing can be aborted by throwing an error.
|
|
||||||
|
|
||||||
### bodyParser.urlencoded([options])
|
|
||||||
|
|
||||||
Returns middleware that only parses `urlencoded` bodies and only looks at
|
|
||||||
requests where the `Content-Type` header matches the `type` option. This
|
|
||||||
parser accepts only UTF-8 and ISO-8859-1 encodings of the body and supports
|
|
||||||
automatic inflation of `gzip`, `br` (brotli) and `deflate` encodings.
|
|
||||||
|
|
||||||
A new `body` object containing the parsed data is populated on the `request`
|
|
||||||
object after the middleware (i.e. `req.body`). This object will contain
|
|
||||||
key-value pairs, where the value can be a string or array (when `extended` is
|
|
||||||
`false`), or any type (when `extended` is `true`).
|
|
||||||
|
|
||||||
#### Options
|
|
||||||
|
|
||||||
The `urlencoded` function takes an optional `options` object that may contain
|
|
||||||
any of the following keys:
|
|
||||||
|
|
||||||
##### extended
|
|
||||||
|
|
||||||
The "extended" syntax allows for rich objects and arrays to be encoded into the
|
|
||||||
URL-encoded format, allowing for a JSON-like experience with URL-encoded. For
|
|
||||||
more information, please [see the qs
|
|
||||||
library](https://www.npmjs.com/package/qs#readme).
|
|
||||||
|
|
||||||
Defaults to `false`.
|
|
||||||
|
|
||||||
##### inflate
|
|
||||||
|
|
||||||
When set to `true`, then deflated (compressed) bodies will be inflated; when
|
|
||||||
`false`, deflated bodies are rejected. Defaults to `true`.
|
|
||||||
|
|
||||||
##### limit
|
|
||||||
|
|
||||||
Controls the maximum request body size. If this is a number, then the value
|
|
||||||
specifies the number of bytes; if it is a string, the value is passed to the
|
|
||||||
[bytes](https://www.npmjs.com/package/bytes) library for parsing. Defaults
|
|
||||||
to `'100kb'`.
|
|
||||||
|
|
||||||
> It’s recommended not to configure a very high limit and to use the default value whenever possible. Allowing larger payloads increases memory usage because of the resources required for decoding and transformations, and it can also lead to longer response times as more data is processed. By ‘very high’, we mean values above the default, for example payloads of 5 MB or more can already start to introduce these risks. With the default limits, these issues do not occur.
|
|
||||||
|
|
||||||
##### parameterLimit
|
|
||||||
|
|
||||||
The `parameterLimit` option controls the maximum number of parameters that
|
|
||||||
are allowed in the URL-encoded data. If a request contains more parameters
|
|
||||||
than this value, a 413 will be returned to the client. Defaults to `1000`.
|
|
||||||
|
|
||||||
##### type
|
|
||||||
|
|
||||||
The `type` option is used to determine what media type the middleware will
|
|
||||||
parse. This option can be a string, array of strings, or a function. If not
|
|
||||||
a function, `type` option is passed directly to the
|
|
||||||
[type-is](https://www.npmjs.com/package/type-is#readme) library and this can
|
|
||||||
be an extension name (like `urlencoded`), a mime type (like
|
|
||||||
`application/x-www-form-urlencoded`), or a mime type with a wildcard (like
|
|
||||||
`*/x-www-form-urlencoded`). If a function, the `type` option is called as
|
|
||||||
`fn(req)` and the request is parsed if it returns a truthy value. Defaults
|
|
||||||
to `application/x-www-form-urlencoded`.
|
|
||||||
|
|
||||||
##### verify
|
|
||||||
|
|
||||||
The `verify` option, if supplied, is called as `verify(req, res, buf, encoding)`,
|
|
||||||
where `buf` is a `Buffer` of the raw request body and `encoding` is the
|
|
||||||
encoding of the request. The parsing can be aborted by throwing an error.
|
|
||||||
|
|
||||||
##### defaultCharset
|
|
||||||
|
|
||||||
The default charset to parse as, if not specified in content-type. Must be
|
|
||||||
either `utf-8` or `iso-8859-1`. Defaults to `utf-8`.
|
|
||||||
|
|
||||||
##### charsetSentinel
|
|
||||||
|
|
||||||
Whether to let the value of the `utf8` parameter take precedence as the charset
|
|
||||||
selector. It requires the form to contain a parameter named `utf8` with a value
|
|
||||||
of `✓`. Defaults to `false`.
|
|
||||||
|
|
||||||
##### interpretNumericEntities
|
|
||||||
|
|
||||||
Whether to decode numeric entities such as `☺` when parsing an iso-8859-1
|
|
||||||
form. Defaults to `false`.
|
|
||||||
|
|
||||||
|
|
||||||
##### depth
|
|
||||||
|
|
||||||
The `depth` option is used to configure the maximum depth of the `qs` library when `extended` is `true`. This allows you to limit the amount of keys that are parsed and can be useful to prevent certain types of abuse. Defaults to `32`. It is recommended to keep this value as low as possible.
|
|
||||||
|
|
||||||
## Errors
|
|
||||||
|
|
||||||
The middlewares provided by this module create errors using the
|
|
||||||
[`http-errors` module](https://www.npmjs.com/package/http-errors). The errors
|
|
||||||
will typically have a `status`/`statusCode` property that contains the suggested
|
|
||||||
HTTP response code, an `expose` property to determine if the `message` property
|
|
||||||
should be displayed to the client, a `type` property to determine the type of
|
|
||||||
error without matching against the `message`, and a `body` property containing
|
|
||||||
the read body, if available.
|
|
||||||
|
|
||||||
The following are the common errors created, though any error can come through
|
|
||||||
for various reasons.
|
|
||||||
|
|
||||||
### content encoding unsupported
|
|
||||||
|
|
||||||
This error will occur when the request had a `Content-Encoding` header that
|
|
||||||
contained an encoding but the "inflation" option was set to `false`. The
|
|
||||||
`status` property is set to `415`, the `type` property is set to
|
|
||||||
`'encoding.unsupported'`, and the `charset` property will be set to the
|
|
||||||
encoding that is unsupported.
|
|
||||||
|
|
||||||
### entity parse failed
|
|
||||||
|
|
||||||
This error will occur when the request contained an entity that could not be
|
|
||||||
parsed by the middleware. The `status` property is set to `400`, the `type`
|
|
||||||
property is set to `'entity.parse.failed'`, and the `body` property is set to
|
|
||||||
the entity value that failed parsing.
|
|
||||||
|
|
||||||
### entity verify failed
|
|
||||||
|
|
||||||
This error will occur when the request contained an entity that could not be
|
|
||||||
failed verification by the defined `verify` option. The `status` property is
|
|
||||||
set to `403`, the `type` property is set to `'entity.verify.failed'`, and the
|
|
||||||
`body` property is set to the entity value that failed verification.
|
|
||||||
|
|
||||||
### request aborted
|
|
||||||
|
|
||||||
This error will occur when the request is aborted by the client before reading
|
|
||||||
the body has finished. The `received` property will be set to the number of
|
|
||||||
bytes received before the request was aborted and the `expected` property is
|
|
||||||
set to the number of expected bytes. The `status` property is set to `400`
|
|
||||||
and `type` property is set to `'request.aborted'`.
|
|
||||||
|
|
||||||
### request entity too large
|
|
||||||
|
|
||||||
This error will occur when the request body's size is larger than the "limit"
|
|
||||||
option. The `limit` property will be set to the byte limit and the `length`
|
|
||||||
property will be set to the request body's length. The `status` property is
|
|
||||||
set to `413` and the `type` property is set to `'entity.too.large'`.
|
|
||||||
|
|
||||||
### request size did not match content length
|
|
||||||
|
|
||||||
This error will occur when the request's length did not match the length from
|
|
||||||
the `Content-Length` header. This typically occurs when the request is malformed,
|
|
||||||
typically when the `Content-Length` header was calculated based on characters
|
|
||||||
instead of bytes. The `status` property is set to `400` and the `type` property
|
|
||||||
is set to `'request.size.invalid'`.
|
|
||||||
|
|
||||||
### stream encoding should not be set
|
|
||||||
|
|
||||||
This error will occur when something called the `req.setEncoding` method prior
|
|
||||||
to this middleware. This module operates directly on bytes only and you cannot
|
|
||||||
call `req.setEncoding` when using this module. The `status` property is set to
|
|
||||||
`500` and the `type` property is set to `'stream.encoding.set'`.
|
|
||||||
|
|
||||||
### stream is not readable
|
|
||||||
|
|
||||||
This error will occur when the request is no longer readable when this middleware
|
|
||||||
attempts to read it. This typically means something other than a middleware from
|
|
||||||
this module read the request body already and the middleware was also configured to
|
|
||||||
read the same request. The `status` property is set to `500` and the `type`
|
|
||||||
property is set to `'stream.not.readable'`.
|
|
||||||
|
|
||||||
### too many parameters
|
|
||||||
|
|
||||||
This error will occur when the content of the request exceeds the configured
|
|
||||||
`parameterLimit` for the `urlencoded` parser. The `status` property is set to
|
|
||||||
`413` and the `type` property is set to `'parameters.too.many'`.
|
|
||||||
|
|
||||||
### unsupported charset "BOGUS"
|
|
||||||
|
|
||||||
This error will occur when the request had a charset parameter in the
|
|
||||||
`Content-Type` header, but the `iconv-lite` module does not support it OR the
|
|
||||||
parser does not support it. The charset is contained in the message as well
|
|
||||||
as in the `charset` property. The `status` property is set to `415`, the
|
|
||||||
`type` property is set to `'charset.unsupported'`, and the `charset` property
|
|
||||||
is set to the charset that is unsupported.
|
|
||||||
|
|
||||||
### unsupported content encoding "bogus"
|
|
||||||
|
|
||||||
This error will occur when the request had a `Content-Encoding` header that
|
|
||||||
contained an unsupported encoding. The encoding is contained in the message
|
|
||||||
as well as in the `encoding` property. The `status` property is set to `415`,
|
|
||||||
the `type` property is set to `'encoding.unsupported'`, and the `encoding`
|
|
||||||
property is set to the encoding that is unsupported.
|
|
||||||
|
|
||||||
### The input exceeded the depth
|
|
||||||
|
|
||||||
This error occurs when using `bodyParser.urlencoded` with the `extended` property set to `true` and the input exceeds the configured `depth` option. The `status` property is set to `400`. It is recommended to review the `depth` option and evaluate if it requires a higher value. When the `depth` option is set to `32` (default value), the error will not be thrown.
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Express/Connect top-level generic
|
|
||||||
|
|
||||||
This example demonstrates adding a generic JSON and URL-encoded parser as a
|
|
||||||
top-level middleware, which will parse the bodies of all incoming requests.
|
|
||||||
This is the simplest setup.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const express = require('express')
|
|
||||||
const bodyParser = require('body-parser')
|
|
||||||
|
|
||||||
const app = express()
|
|
||||||
|
|
||||||
// parse application/x-www-form-urlencoded
|
|
||||||
app.use(bodyParser.urlencoded())
|
|
||||||
|
|
||||||
// parse application/json
|
|
||||||
app.use(bodyParser.json())
|
|
||||||
|
|
||||||
app.use(function (req, res) {
|
|
||||||
res.setHeader('Content-Type', 'text/plain')
|
|
||||||
res.write('you posted:\n')
|
|
||||||
res.end(String(JSON.stringify(req.body, null, 2)))
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Express route-specific
|
|
||||||
|
|
||||||
This example demonstrates adding body parsers specifically to the routes that
|
|
||||||
need them. In general, this is the most recommended way to use body-parser with
|
|
||||||
Express.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const express = require('express')
|
|
||||||
const bodyParser = require('body-parser')
|
|
||||||
|
|
||||||
const app = express()
|
|
||||||
|
|
||||||
// create application/json parser
|
|
||||||
const jsonParser = bodyParser.json()
|
|
||||||
|
|
||||||
// create application/x-www-form-urlencoded parser
|
|
||||||
const urlencodedParser = bodyParser.urlencoded()
|
|
||||||
|
|
||||||
// POST /login gets urlencoded bodies
|
|
||||||
app.post('/login', urlencodedParser, function (req, res) {
|
|
||||||
if (!req.body || !req.body.username) res.sendStatus(400)
|
|
||||||
res.send('welcome, ' + req.body.username)
|
|
||||||
})
|
|
||||||
|
|
||||||
// POST /api/users gets JSON bodies
|
|
||||||
app.post('/api/users', jsonParser, function (req, res) {
|
|
||||||
if (!req.body) res.sendStatus(400)
|
|
||||||
// create user in req.body
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Change accepted type for parsers
|
|
||||||
|
|
||||||
All the parsers accept a `type` option which allows you to change the
|
|
||||||
`Content-Type` that the middleware will parse.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const express = require('express')
|
|
||||||
const bodyParser = require('body-parser')
|
|
||||||
|
|
||||||
const app = express()
|
|
||||||
|
|
||||||
// parse various different custom JSON types as JSON
|
|
||||||
app.use(bodyParser.json({ type: 'application/*+json' }))
|
|
||||||
|
|
||||||
// parse some custom thing into a Buffer
|
|
||||||
app.use(bodyParser.raw({ type: 'application/vnd.custom-type' }))
|
|
||||||
|
|
||||||
// parse an HTML body into a string
|
|
||||||
app.use(bodyParser.text({ type: 'text/html' }))
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[MIT](LICENSE)
|
|
||||||
|
|
||||||
[ci-image]: https://img.shields.io/github/actions/workflow/status/expressjs/body-parser/ci.yml?branch=master&label=ci
|
|
||||||
[ci-url]: https://github.com/expressjs/body-parser/actions/workflows/ci.yml
|
|
||||||
[coveralls-image]: https://img.shields.io/coverallsCoverage/github/expressjs/body-parser?branch=master
|
|
||||||
[coveralls-url]: https://coveralls.io/r/expressjs/body-parser?branch=master
|
|
||||||
[npm-downloads-image]: https://img.shields.io/npm/dm/body-parser
|
|
||||||
[npm-url]: https://npmjs.com/package/body-parser
|
|
||||||
[npm-version-image]: https://img.shields.io/npm/v/body-parser
|
|
||||||
[ossf-scorecard-badge]: https://api.scorecard.dev/projects/github.com/expressjs/body-parser/badge
|
|
||||||
[ossf-scorecard-visualizer]: https://ossf.github.io/scorecard-visualizer/#/projects/github.com/expressjs/body-parser
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
/*!
|
|
||||||
* body-parser
|
|
||||||
* Copyright(c) 2014-2015 Douglas Christopher Wilson
|
|
||||||
* MIT Licensed
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} Parsers
|
|
||||||
* @property {Function} json JSON parser
|
|
||||||
* @property {Function} raw Raw parser
|
|
||||||
* @property {Function} text Text parser
|
|
||||||
* @property {Function} urlencoded URL-encoded parser
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module exports.
|
|
||||||
* @type {Function & Parsers}
|
|
||||||
*/
|
|
||||||
exports = module.exports = bodyParser
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON parser.
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
exports.json = require('./lib/types/json')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Raw parser.
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
exports.raw = require('./lib/types/raw')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Text parser.
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
exports.text = require('./lib/types/text')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* URL-encoded parser.
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
exports.urlencoded = require('./lib/types/urlencoded')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a middleware to parse json and urlencoded bodies.
|
|
||||||
*
|
|
||||||
* @deprecated
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
function bodyParser () {
|
|
||||||
throw new Error('The bodyParser() generic has been split into individual middleware to use instead.')
|
|
||||||
}
|
|
||||||
|
|
@ -1,247 +0,0 @@
|
||||||
/*!
|
|
||||||
* body-parser
|
|
||||||
* Copyright(c) 2014-2015 Douglas Christopher Wilson
|
|
||||||
* MIT Licensed
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module dependencies.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
const createError = require('http-errors')
|
|
||||||
const getBody = require('raw-body')
|
|
||||||
const iconv = require('iconv-lite')
|
|
||||||
const onFinished = require('on-finished')
|
|
||||||
const zlib = require('node:zlib')
|
|
||||||
const hasBody = require('type-is').hasBody
|
|
||||||
const { getCharset } = require('./utils')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module exports.
|
|
||||||
*/
|
|
||||||
|
|
||||||
module.exports = read
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read a request into a buffer and parse.
|
|
||||||
*
|
|
||||||
* @param {Object} req
|
|
||||||
* @param {Object} res
|
|
||||||
* @param {Function} next
|
|
||||||
* @param {Function} parse
|
|
||||||
* @param {Function} debug
|
|
||||||
* @param {Object} options
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function read (req, res, next, parse, debug, options) {
|
|
||||||
if (onFinished.isFinished(req)) {
|
|
||||||
debug('body already parsed')
|
|
||||||
next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!('body' in req)) {
|
|
||||||
req.body = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
// skip requests without bodies
|
|
||||||
if (!hasBody(req)) {
|
|
||||||
debug('skip empty body')
|
|
||||||
next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
debug('content-type %j', req.headers['content-type'])
|
|
||||||
|
|
||||||
// determine if request should be parsed
|
|
||||||
if (!options.shouldParse(req)) {
|
|
||||||
debug('skip parsing')
|
|
||||||
next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let encoding = null
|
|
||||||
if (options?.skipCharset !== true) {
|
|
||||||
encoding = getCharset(req) || options.defaultCharset
|
|
||||||
|
|
||||||
// validate charset
|
|
||||||
if (!!options?.isValidCharset && !options.isValidCharset(encoding)) {
|
|
||||||
debug('invalid charset')
|
|
||||||
next(createError(415, 'unsupported charset "' + encoding.toUpperCase() + '"', {
|
|
||||||
charset: encoding,
|
|
||||||
type: 'charset.unsupported'
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let length
|
|
||||||
const opts = options
|
|
||||||
let stream
|
|
||||||
|
|
||||||
// read options
|
|
||||||
const verify = opts.verify
|
|
||||||
|
|
||||||
try {
|
|
||||||
// get the content stream
|
|
||||||
stream = contentstream(req, debug, opts.inflate)
|
|
||||||
length = stream.length
|
|
||||||
stream.length = undefined
|
|
||||||
} catch (err) {
|
|
||||||
return next(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// set raw-body options
|
|
||||||
opts.length = length
|
|
||||||
opts.encoding = verify
|
|
||||||
? null
|
|
||||||
: encoding
|
|
||||||
|
|
||||||
// assert charset is supported
|
|
||||||
if (opts.encoding === null && encoding !== null && !iconv.encodingExists(encoding)) {
|
|
||||||
return next(createError(415, 'unsupported charset "' + encoding.toUpperCase() + '"', {
|
|
||||||
charset: encoding.toLowerCase(),
|
|
||||||
type: 'charset.unsupported'
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// read body
|
|
||||||
debug('read body')
|
|
||||||
getBody(stream, opts, function (error, body) {
|
|
||||||
if (error) {
|
|
||||||
let _error
|
|
||||||
|
|
||||||
if (error.type === 'encoding.unsupported') {
|
|
||||||
// echo back charset
|
|
||||||
_error = createError(415, 'unsupported charset "' + encoding.toUpperCase() + '"', {
|
|
||||||
charset: encoding.toLowerCase(),
|
|
||||||
type: 'charset.unsupported'
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// set status code on error
|
|
||||||
_error = createError(400, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// unpipe from stream and destroy
|
|
||||||
if (stream !== req) {
|
|
||||||
req.unpipe()
|
|
||||||
stream.destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
// read off entire request
|
|
||||||
dump(req, function onfinished () {
|
|
||||||
next(createError(400, _error))
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// verify
|
|
||||||
if (verify) {
|
|
||||||
try {
|
|
||||||
debug('verify body')
|
|
||||||
verify(req, res, body, encoding)
|
|
||||||
} catch (err) {
|
|
||||||
next(createError(403, err, {
|
|
||||||
body: body,
|
|
||||||
type: err.type || 'entity.verify.failed'
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parse
|
|
||||||
let str = body
|
|
||||||
try {
|
|
||||||
debug('parse body')
|
|
||||||
str = typeof body !== 'string' && encoding !== null
|
|
||||||
? iconv.decode(body, encoding)
|
|
||||||
: body
|
|
||||||
req.body = parse(str, encoding)
|
|
||||||
} catch (err) {
|
|
||||||
next(createError(400, err, {
|
|
||||||
body: str,
|
|
||||||
type: err.type || 'entity.parse.failed'
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
next()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the content stream of the request.
|
|
||||||
*
|
|
||||||
* @param {Object} req
|
|
||||||
* @param {Function} debug
|
|
||||||
* @param {boolean} inflate
|
|
||||||
* @returns {Object}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function contentstream (req, debug, inflate) {
|
|
||||||
const encoding = (req.headers['content-encoding'] || 'identity').toLowerCase()
|
|
||||||
const length = req.headers['content-length']
|
|
||||||
|
|
||||||
debug('content-encoding "%s"', encoding)
|
|
||||||
|
|
||||||
if (inflate === false && encoding !== 'identity') {
|
|
||||||
throw createError(415, 'content encoding unsupported', {
|
|
||||||
encoding: encoding,
|
|
||||||
type: 'encoding.unsupported'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (encoding === 'identity') {
|
|
||||||
req.length = length
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
const stream = createDecompressionStream(encoding, debug)
|
|
||||||
req.pipe(stream)
|
|
||||||
return stream
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a decompression stream for the given encoding.
|
|
||||||
* @param {string} encoding
|
|
||||||
* @param {Function} debug
|
|
||||||
* @returns {Object}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function createDecompressionStream (encoding, debug) {
|
|
||||||
switch (encoding) {
|
|
||||||
case 'deflate':
|
|
||||||
debug('inflate body')
|
|
||||||
return zlib.createInflate()
|
|
||||||
case 'gzip':
|
|
||||||
debug('gunzip body')
|
|
||||||
return zlib.createGunzip()
|
|
||||||
case 'br':
|
|
||||||
debug('brotli decompress body')
|
|
||||||
return zlib.createBrotliDecompress()
|
|
||||||
default:
|
|
||||||
throw createError(415, 'unsupported content encoding "' + encoding + '"', {
|
|
||||||
encoding: encoding,
|
|
||||||
type: 'encoding.unsupported'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dump the contents of a request.
|
|
||||||
*
|
|
||||||
* @param {Object} req
|
|
||||||
* @param {Function} callback
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function dump (req, callback) {
|
|
||||||
if (onFinished.isFinished(req)) {
|
|
||||||
callback(null)
|
|
||||||
} else {
|
|
||||||
onFinished(req, callback)
|
|
||||||
req.resume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
/*!
|
|
||||||
* body-parser
|
|
||||||
* Copyright(c) 2014 Jonathan Ong
|
|
||||||
* Copyright(c) 2014-2015 Douglas Christopher Wilson
|
|
||||||
* MIT Licensed
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module dependencies.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
const debug = require('debug')('body-parser:json')
|
|
||||||
const read = require('../read')
|
|
||||||
const { normalizeOptions } = require('../utils')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module exports.
|
|
||||||
*/
|
|
||||||
|
|
||||||
module.exports = json
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RegExp to match the first non-space in a string.
|
|
||||||
*
|
|
||||||
* Allowed whitespace is defined in RFC 7159:
|
|
||||||
*
|
|
||||||
* ws = *(
|
|
||||||
* %x20 / ; Space
|
|
||||||
* %x09 / ; Horizontal tab
|
|
||||||
* %x0A / ; Line feed or New line
|
|
||||||
* %x0D ) ; Carriage return
|
|
||||||
*/
|
|
||||||
const FIRST_CHAR_REGEXP = /^[\x20\x09\x0a\x0d]*([^\x20\x09\x0a\x0d])/ // eslint-disable-line no-control-regex
|
|
||||||
|
|
||||||
const JSON_SYNTAX_CHAR = '#'
|
|
||||||
const JSON_SYNTAX_REGEXP = /#+/g
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a middleware to parse JSON bodies.
|
|
||||||
*
|
|
||||||
* @param {Object} [options]
|
|
||||||
* @returns {Function}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
function json (options) {
|
|
||||||
const normalizedOptions = normalizeOptions(options, 'application/json')
|
|
||||||
|
|
||||||
const parse = createJsonParser(options)
|
|
||||||
|
|
||||||
const readOptions = {
|
|
||||||
...normalizedOptions,
|
|
||||||
// assert charset per RFC 7159 sec 8.1
|
|
||||||
isValidCharset: (charset) => charset.slice(0, 4) === 'utf-'
|
|
||||||
}
|
|
||||||
|
|
||||||
return function jsonParser (req, res, next) {
|
|
||||||
read(req, res, next, parse, debug, readOptions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a JSON parse function
|
|
||||||
*
|
|
||||||
* @param {object} [options]
|
|
||||||
* @return {function}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function createJsonParser (options) {
|
|
||||||
const reviver = options?.reviver
|
|
||||||
const strict = options?.strict !== false
|
|
||||||
|
|
||||||
if (strict) {
|
|
||||||
return function parse (body) {
|
|
||||||
if (body.length === 0) {
|
|
||||||
// special-case empty json body, as it's a common client-side mistake
|
|
||||||
// TODO: maybe make this configurable or part of "strict" option
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const first = firstchar(body)
|
|
||||||
if (first !== '{' && first !== '[') {
|
|
||||||
debug('strict violation')
|
|
||||||
throw createStrictSyntaxError(body, first)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
debug('parse json')
|
|
||||||
return JSON.parse(body, reviver)
|
|
||||||
} catch (e) {
|
|
||||||
throw normalizeJsonSyntaxError(e, {
|
|
||||||
message: e.message,
|
|
||||||
stack: e.stack
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return function parse (body) {
|
|
||||||
if (body.length === 0) {
|
|
||||||
// special-case empty json body, as it's a common client-side mistake
|
|
||||||
// TODO: maybe make this configurable or part of "strict" option
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
debug('parse json')
|
|
||||||
return JSON.parse(body, reviver)
|
|
||||||
} catch (e) {
|
|
||||||
throw normalizeJsonSyntaxError(e, {
|
|
||||||
message: e.message,
|
|
||||||
stack: e.stack
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create strict violation syntax error matching native error.
|
|
||||||
*
|
|
||||||
* @param {string} str
|
|
||||||
* @param {string} char
|
|
||||||
* @returns {Error}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function createStrictSyntaxError (str, char) {
|
|
||||||
const index = str.indexOf(char)
|
|
||||||
let partial = ''
|
|
||||||
|
|
||||||
if (index !== -1) {
|
|
||||||
partial = str.substring(0, index) + JSON_SYNTAX_CHAR.repeat(str.length - index)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
JSON.parse(partial); /* istanbul ignore next */ throw new SyntaxError('strict violation')
|
|
||||||
} catch (e) {
|
|
||||||
return normalizeJsonSyntaxError(e, {
|
|
||||||
message: e.message.replace(JSON_SYNTAX_REGEXP, function (placeholder) {
|
|
||||||
return str.substring(index, index + placeholder.length)
|
|
||||||
}),
|
|
||||||
stack: e.stack
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the first non-whitespace character in a string.
|
|
||||||
*
|
|
||||||
* @param {string} str
|
|
||||||
* @returns {string|undefined}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function firstchar (str) {
|
|
||||||
const match = FIRST_CHAR_REGEXP.exec(str)
|
|
||||||
|
|
||||||
return match
|
|
||||||
? match[1]
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize a SyntaxError for JSON.parse.
|
|
||||||
*
|
|
||||||
* @param {SyntaxError} error
|
|
||||||
* @param {Object} obj
|
|
||||||
* @returns {SyntaxError}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function normalizeJsonSyntaxError (error, obj) {
|
|
||||||
const keys = Object.getOwnPropertyNames(error)
|
|
||||||
|
|
||||||
for (let i = 0; i < keys.length; i++) {
|
|
||||||
const key = keys[i]
|
|
||||||
if (key !== 'stack' && key !== 'message') {
|
|
||||||
delete error[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// replace stack before message for Node.js 0.10 and below
|
|
||||||
error.stack = obj.stack.replace(error.message, obj.message)
|
|
||||||
error.message = obj.message
|
|
||||||
|
|
||||||
return error
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
/*!
|
|
||||||
* body-parser
|
|
||||||
* Copyright(c) 2014-2015 Douglas Christopher Wilson
|
|
||||||
* MIT Licensed
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module dependencies.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const debug = require('debug')('body-parser:raw')
|
|
||||||
const read = require('../read')
|
|
||||||
const { normalizeOptions, passthrough } = require('../utils')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module exports.
|
|
||||||
*/
|
|
||||||
|
|
||||||
module.exports = raw
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a middleware to parse raw bodies.
|
|
||||||
*
|
|
||||||
* @param {Object} [options]
|
|
||||||
* @returns {Function}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
function raw (options) {
|
|
||||||
const normalizedOptions = normalizeOptions(options, 'application/octet-stream')
|
|
||||||
|
|
||||||
const readOptions = {
|
|
||||||
...normalizedOptions,
|
|
||||||
// Skip charset validation and parse the body as is
|
|
||||||
skipCharset: true
|
|
||||||
}
|
|
||||||
|
|
||||||
return function rawParser (req, res, next) {
|
|
||||||
read(req, res, next, passthrough, debug, readOptions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
/*!
|
|
||||||
* body-parser
|
|
||||||
* Copyright(c) 2014-2015 Douglas Christopher Wilson
|
|
||||||
* MIT Licensed
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module dependencies.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const debug = require('debug')('body-parser:text')
|
|
||||||
const read = require('../read')
|
|
||||||
const { normalizeOptions, passthrough } = require('../utils')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module exports.
|
|
||||||
*/
|
|
||||||
|
|
||||||
module.exports = text
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a middleware to parse text bodies.
|
|
||||||
*
|
|
||||||
* @param {Object} [options]
|
|
||||||
* @returns {Function}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
function text (options) {
|
|
||||||
const normalizedOptions = normalizeOptions(options, 'text/plain')
|
|
||||||
|
|
||||||
return function textParser (req, res, next) {
|
|
||||||
read(req, res, next, passthrough, debug, normalizedOptions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
/*!
|
|
||||||
* body-parser
|
|
||||||
* Copyright(c) 2014 Jonathan Ong
|
|
||||||
* Copyright(c) 2014-2015 Douglas Christopher Wilson
|
|
||||||
* MIT Licensed
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module dependencies.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
const createError = require('http-errors')
|
|
||||||
const debug = require('debug')('body-parser:urlencoded')
|
|
||||||
const read = require('../read')
|
|
||||||
const qs = require('qs')
|
|
||||||
const { normalizeOptions } = require('../utils')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module exports.
|
|
||||||
*/
|
|
||||||
|
|
||||||
module.exports = urlencoded
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a middleware to parse urlencoded bodies.
|
|
||||||
*
|
|
||||||
* @param {Object} [options]
|
|
||||||
* @returns {Function}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
function urlencoded (options) {
|
|
||||||
const normalizedOptions = normalizeOptions(options, 'application/x-www-form-urlencoded')
|
|
||||||
|
|
||||||
if (normalizedOptions.defaultCharset !== 'utf-8' && normalizedOptions.defaultCharset !== 'iso-8859-1') {
|
|
||||||
throw new TypeError('option defaultCharset must be either utf-8 or iso-8859-1')
|
|
||||||
}
|
|
||||||
|
|
||||||
// create the appropriate query parser
|
|
||||||
const parse = createQueryParser(options)
|
|
||||||
|
|
||||||
const readOptions = {
|
|
||||||
...normalizedOptions,
|
|
||||||
// assert charset
|
|
||||||
isValidCharset: (charset) => charset === 'utf-8' || charset === 'iso-8859-1'
|
|
||||||
}
|
|
||||||
|
|
||||||
return function urlencodedParser (req, res, next) {
|
|
||||||
read(req, res, next, parse, debug, readOptions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the extended query parser.
|
|
||||||
*
|
|
||||||
* @param {Object} options
|
|
||||||
* @returns {Function}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function createQueryParser (options) {
|
|
||||||
const extended = Boolean(options?.extended)
|
|
||||||
let parameterLimit = options?.parameterLimit !== undefined
|
|
||||||
? options?.parameterLimit
|
|
||||||
: 1000
|
|
||||||
const charsetSentinel = options?.charsetSentinel
|
|
||||||
const interpretNumericEntities = options?.interpretNumericEntities
|
|
||||||
const depth = extended ? (options?.depth !== undefined ? options?.depth : 32) : 0
|
|
||||||
|
|
||||||
if (isNaN(parameterLimit) || parameterLimit < 1) {
|
|
||||||
throw new TypeError('option parameterLimit must be a positive number')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNaN(depth) || depth < 0) {
|
|
||||||
throw new TypeError('option depth must be a zero or a positive number')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFinite(parameterLimit)) {
|
|
||||||
parameterLimit = parameterLimit | 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return function parse (body, encoding) {
|
|
||||||
if (!body.length) return {}
|
|
||||||
|
|
||||||
const paramCount = parameterCount(body, parameterLimit)
|
|
||||||
|
|
||||||
if (paramCount === undefined) {
|
|
||||||
debug('too many parameters')
|
|
||||||
throw createError(413, 'too many parameters', {
|
|
||||||
type: 'parameters.too.many'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const arrayLimit = extended ? Math.max(100, paramCount) : paramCount
|
|
||||||
|
|
||||||
debug('parse ' + (extended ? 'extended ' : '') + 'urlencoding')
|
|
||||||
try {
|
|
||||||
return qs.parse(body, {
|
|
||||||
allowPrototypes: true,
|
|
||||||
arrayLimit: arrayLimit,
|
|
||||||
depth: depth,
|
|
||||||
charsetSentinel: charsetSentinel,
|
|
||||||
interpretNumericEntities: interpretNumericEntities,
|
|
||||||
charset: encoding,
|
|
||||||
parameterLimit: parameterLimit,
|
|
||||||
strictDepth: true
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof RangeError) {
|
|
||||||
throw createError(400, 'The input exceeded the depth', {
|
|
||||||
type: 'querystring.parse.rangeError'
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count the number of parameters, stopping once limit reached
|
|
||||||
*
|
|
||||||
* @param {string} body
|
|
||||||
* @param {number} limit
|
|
||||||
* @returns {number|undefined} Returns undefined if limit exceeded
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function parameterCount (body, limit) {
|
|
||||||
let count = 0
|
|
||||||
let index = -1
|
|
||||||
do {
|
|
||||||
count++
|
|
||||||
if (count > limit) return undefined // Early exit if limit exceeded
|
|
||||||
index = body.indexOf('&', index + 1)
|
|
||||||
} while (index !== -1)
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
'use strict'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module dependencies.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const bytes = require('bytes')
|
|
||||||
const contentType = require('content-type')
|
|
||||||
const typeis = require('type-is')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module exports.
|
|
||||||
*/
|
|
||||||
module.exports = {
|
|
||||||
getCharset,
|
|
||||||
normalizeOptions,
|
|
||||||
passthrough
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the charset of a request.
|
|
||||||
*
|
|
||||||
* @param {Object} req
|
|
||||||
* @returns {string | undefined}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function getCharset (req) {
|
|
||||||
const header = req.headers['content-type']
|
|
||||||
if (!header) return undefined
|
|
||||||
return contentType.parse(header).parameters.charset?.toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the simple type checker.
|
|
||||||
*
|
|
||||||
* @param {string | string[]} type
|
|
||||||
* @returns {Function}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function typeChecker (type) {
|
|
||||||
return function checkType (req) {
|
|
||||||
return Boolean(typeis(req, type))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes the common options for all parsers.
|
|
||||||
*
|
|
||||||
* @param {Object} options options to normalize
|
|
||||||
* @param {string | string[] | Function} defaultType default content type(s) or a function to determine it
|
|
||||||
* @returns {Object}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function normalizeOptions (options, defaultType) {
|
|
||||||
if (!defaultType) {
|
|
||||||
// Parsers must define a default content type
|
|
||||||
throw new TypeError('defaultType must be provided')
|
|
||||||
}
|
|
||||||
|
|
||||||
const inflate = options?.inflate !== false
|
|
||||||
const limit = typeof options?.limit === 'undefined' || options?.limit === null
|
|
||||||
? 102400 // 100kb default
|
|
||||||
: bytes.parse(options.limit)
|
|
||||||
const type = options?.type || defaultType
|
|
||||||
const verify = options?.verify || false
|
|
||||||
const defaultCharset = options?.defaultCharset || 'utf-8'
|
|
||||||
|
|
||||||
if (limit === null) {
|
|
||||||
throw new TypeError(`option limit "${String(options.limit)}" is invalid`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (verify !== false && typeof verify !== 'function') {
|
|
||||||
throw new TypeError('option verify must be function')
|
|
||||||
}
|
|
||||||
|
|
||||||
// create the appropriate type checking function
|
|
||||||
const shouldParse = typeof type !== 'function'
|
|
||||||
? typeChecker(type)
|
|
||||||
: type
|
|
||||||
|
|
||||||
return {
|
|
||||||
inflate,
|
|
||||||
limit,
|
|
||||||
verify,
|
|
||||||
defaultCharset,
|
|
||||||
shouldParse
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Passthrough function that returns input unchanged.
|
|
||||||
* Used by parsers that don't need to transform the data.
|
|
||||||
*
|
|
||||||
* @param {*} value
|
|
||||||
* @returns {*}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function passthrough (value) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
(The MIT License)
|
|
||||||
|
|
||||||
Copyright (c) 2015 Douglas Christopher Wilson
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of this software and associated documentation files (the
|
|
||||||
'Software'), to deal in the Software without restriction, including
|
|
||||||
without limitation the rights to use, copy, modify, merge, publish,
|
|
||||||
distribute, sublicense, and/or sell copies of the Software, and to
|
|
||||||
permit persons to whom the Software is furnished to do so, subject to
|
|
||||||
the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be
|
|
||||||
included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
||||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
||||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
||||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
||||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
# content-type
|
|
||||||
|
|
||||||
[![NPM version][npm-image]][npm-url]
|
|
||||||
[![NPM downloads][downloads-image]][downloads-url]
|
|
||||||
[![Build status][build-image]][build-url]
|
|
||||||
[![Build coverage][coverage-image]][coverage-url]
|
|
||||||
[![License][license-image]][license-url]
|
|
||||||
|
|
||||||
Create and parse HTTP `Content-Type` header.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm install content-type
|
|
||||||
```
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
```js
|
|
||||||
const contentType = require("content-type");
|
|
||||||
```
|
|
||||||
|
|
||||||
### contentType.parse(string, options?)
|
|
||||||
|
|
||||||
```js
|
|
||||||
const obj = contentType.parse("image/svg+xml; charset=utf-8");
|
|
||||||
```
|
|
||||||
|
|
||||||
Parse a `Content-Type` header. This will return an object with the following properties (examples are shown for the string `'image/svg+xml; charset=utf-8'`):
|
|
||||||
|
|
||||||
- `type`: The media type. Example: `'image/svg+xml'`.
|
|
||||||
- `parameters`: An object of the parameters in the media type (parameter name is always lower case). Example: `{charset: 'utf-8'}`.
|
|
||||||
|
|
||||||
The parser is lenient and does not error. You should validate `type` and `parameters` before trusting them.
|
|
||||||
|
|
||||||
#### Options
|
|
||||||
|
|
||||||
- `parameters` (default: `true`): Set to `false` to skip parameters.
|
|
||||||
|
|
||||||
### contentType.format(obj)
|
|
||||||
|
|
||||||
```js
|
|
||||||
const str = contentType.format({
|
|
||||||
type: "image/svg+xml",
|
|
||||||
parameters: { charset: "utf-8" },
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Format an object into a `Content-Type` header. This will return a string of the content type for the given object with the following properties (examples are shown that produce the string `'image/svg+xml; charset=utf-8'`):
|
|
||||||
|
|
||||||
- `type`: The media type. Example: `'image/svg+xml'`.
|
|
||||||
- `parameters`: An optional object of the parameters in the media type. Example: `{charset: 'utf-8'}`.
|
|
||||||
|
|
||||||
Throws a `TypeError` if the object contains an invalid type or parameter names.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[MIT](LICENSE)
|
|
||||||
|
|
||||||
[npm-image]: https://img.shields.io/npm/v/content-type
|
|
||||||
[npm-url]: https://npmjs.org/package/content-type
|
|
||||||
[downloads-image]: https://img.shields.io/npm/dm/content-type
|
|
||||||
[downloads-url]: https://npmjs.org/package/content-type
|
|
||||||
[build-image]: https://img.shields.io/github/actions/workflow/status/jshttp/content-type/ci.yml?branch=master
|
|
||||||
[build-url]: https://github.com/jshttp/content-type/actions/workflows/ci.yml?query=branch%3Amaster
|
|
||||||
[coverage-image]: https://img.shields.io/codecov/c/gh/jshttp/content-type
|
|
||||||
[coverage-url]: https://codecov.io/gh/jshttp/content-type
|
|
||||||
[license-image]: http://img.shields.io/npm/l/content-type.svg?style=flat
|
|
||||||
[license-url]: LICENSE
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
/*!
|
|
||||||
* content-type
|
|
||||||
* Copyright(c) 2015 Douglas Christopher Wilson
|
|
||||||
* MIT Licensed
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* The content type object contains a type string and optional parameters.
|
|
||||||
*/
|
|
||||||
export interface ContentType {
|
|
||||||
type: string;
|
|
||||||
parameters: Record<string, string>;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Format an object into a `Content-Type` header.
|
|
||||||
*/
|
|
||||||
export declare function format(obj: Partial<ContentType>): string;
|
|
||||||
/**
|
|
||||||
* Options for parsing a `Content-Type` header.
|
|
||||||
*/
|
|
||||||
export interface ParseOptions {
|
|
||||||
parameters?: boolean;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Parse a `Content-Type` header.
|
|
||||||
*/
|
|
||||||
export declare function parse(header: string, options?: ParseOptions): ContentType;
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
"use strict";
|
|
||||||
/*!
|
|
||||||
* content-type
|
|
||||||
* Copyright(c) 2015 Douglas Christopher Wilson
|
|
||||||
* MIT Licensed
|
|
||||||
*/
|
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
|
||||||
exports.format = format;
|
|
||||||
exports.parse = parse;
|
|
||||||
const TEXT_REGEXP = /^[\u0009\u0020-\u007e\u0080-\u00ff]*$/;
|
|
||||||
const TOKEN_REGEXP = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
||||||
/**
|
|
||||||
* RegExp to match chars that must be quoted-pair in RFC 9110 sec 5.6.4
|
|
||||||
*/
|
|
||||||
const QUOTE_REGEXP = /[\\"]/g;
|
|
||||||
/**
|
|
||||||
* RegExp to match type in RFC 9110 sec 8.3.1
|
|
||||||
*
|
|
||||||
* media-type = type "/" subtype
|
|
||||||
* type = token
|
|
||||||
* subtype = token
|
|
||||||
*/
|
|
||||||
const TYPE_REGEXP = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+\/[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
||||||
/**
|
|
||||||
* Null object perf optimization. Faster than `Object.create(null)` and `{ __proto__: null }`.
|
|
||||||
*/
|
|
||||||
const NullObject = /* @__PURE__ */ (() => {
|
|
||||||
const C = function () { };
|
|
||||||
C.prototype = Object.create(null);
|
|
||||||
return C;
|
|
||||||
})();
|
|
||||||
/**
|
|
||||||
* Format an object into a `Content-Type` header.
|
|
||||||
*/
|
|
||||||
function format(obj) {
|
|
||||||
const { type, parameters } = obj;
|
|
||||||
if (!type || !TYPE_REGEXP.test(type)) {
|
|
||||||
throw new TypeError(`Invalid type: ${type}`);
|
|
||||||
}
|
|
||||||
let result = type;
|
|
||||||
if (parameters) {
|
|
||||||
for (const param of Object.keys(parameters)) {
|
|
||||||
if (!TOKEN_REGEXP.test(param)) {
|
|
||||||
throw new TypeError(`Invalid parameter name: ${param}`);
|
|
||||||
}
|
|
||||||
result += `; ${param}=${qstring(parameters[param])}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Parse a `Content-Type` header.
|
|
||||||
*/
|
|
||||||
function parse(header, options) {
|
|
||||||
const len = header.length;
|
|
||||||
let index = skipOWS(header, 0, len);
|
|
||||||
const valueStart = index;
|
|
||||||
index = skipValue(header, index, len);
|
|
||||||
const valueEnd = trailingOWS(header, valueStart, index);
|
|
||||||
const type = header.slice(valueStart, valueEnd).toLowerCase();
|
|
||||||
const parameters = options?.parameters === false
|
|
||||||
? new NullObject()
|
|
||||||
: parseParameters(header, index, len);
|
|
||||||
return { type, parameters };
|
|
||||||
}
|
|
||||||
const SP = 32; // " "
|
|
||||||
const HTAB = 9; // "\t"
|
|
||||||
const SEMI = 59; // ";"
|
|
||||||
const EQ = 61; // "="
|
|
||||||
const DQUOTE = 34; // '"'
|
|
||||||
const BSLASH = 92; // "\\"
|
|
||||||
/**
|
|
||||||
* Parses the parameters of a `Content-Type` header starting at the given index.
|
|
||||||
*/
|
|
||||||
function parseParameters(header, index, len) {
|
|
||||||
const parameters = new NullObject();
|
|
||||||
parameter: while (index < len) {
|
|
||||||
index = skipOWS(header, index + 1 /* Skip over ; */, len);
|
|
||||||
const keyStart = index;
|
|
||||||
while (index < len) {
|
|
||||||
const code = header.charCodeAt(index);
|
|
||||||
if (code === SEMI)
|
|
||||||
continue parameter;
|
|
||||||
if (code === EQ) {
|
|
||||||
const keyEnd = trailingOWS(header, keyStart, index);
|
|
||||||
const key = header.slice(keyStart, keyEnd).toLowerCase();
|
|
||||||
index = skipOWS(header, index + 1, len);
|
|
||||||
if (index < len && header.charCodeAt(index) === DQUOTE) {
|
|
||||||
index++;
|
|
||||||
let value = "";
|
|
||||||
while (index < len) {
|
|
||||||
const code = header.charCodeAt(index++);
|
|
||||||
if (code === DQUOTE) {
|
|
||||||
index = skipValue(header, index, len);
|
|
||||||
if (parameters[key] === undefined)
|
|
||||||
parameters[key] = value;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (code === BSLASH && index < len) {
|
|
||||||
value += header[index++];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
value += String.fromCharCode(code);
|
|
||||||
}
|
|
||||||
continue parameter;
|
|
||||||
}
|
|
||||||
const valueStart = index;
|
|
||||||
index = skipValue(header, index, len);
|
|
||||||
if (parameters[key] === undefined) {
|
|
||||||
const valueEnd = trailingOWS(header, valueStart, index);
|
|
||||||
parameters[key] = header.slice(valueStart, valueEnd);
|
|
||||||
}
|
|
||||||
continue parameter;
|
|
||||||
}
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parameters;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Skip over characters until a semicolon.
|
|
||||||
*/
|
|
||||||
function skipValue(str, index, len) {
|
|
||||||
while (index < len) {
|
|
||||||
const char = str.charCodeAt(index);
|
|
||||||
if (char === SEMI)
|
|
||||||
break;
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Skip optional whitespace (OWS) in an HTTP header value.
|
|
||||||
*
|
|
||||||
* OWS is defined in RFC 9110 sec 5.6.3 as SP (" ") or HTAB ("\t").
|
|
||||||
*/
|
|
||||||
function skipOWS(header, index, len) {
|
|
||||||
while (index < len) {
|
|
||||||
const char = header.charCodeAt(index);
|
|
||||||
if (char !== SP && char !== HTAB)
|
|
||||||
break;
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Trim optional whitespace (OWS) from the end of a substring.
|
|
||||||
*
|
|
||||||
* OWS is defined in RFC 9110 sec 5.6.3 as SP (" ") or HTAB ("\t").
|
|
||||||
*/
|
|
||||||
function trailingOWS(header, start, end) {
|
|
||||||
while (end > start) {
|
|
||||||
const char = header.charCodeAt(end - 1);
|
|
||||||
if (char !== SP && char !== HTAB)
|
|
||||||
break;
|
|
||||||
end--;
|
|
||||||
}
|
|
||||||
return end;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Serialize a parameter value.
|
|
||||||
*/
|
|
||||||
function qstring(str) {
|
|
||||||
if (TOKEN_REGEXP.test(str))
|
|
||||||
return str;
|
|
||||||
if (TEXT_REGEXP.test(str))
|
|
||||||
return `"${str.replace(QUOTE_REGEXP, "\\$&")}"`;
|
|
||||||
throw new TypeError(`Invalid parameter value: ${str}`);
|
|
||||||
}
|
|
||||||
//# sourceMappingURL=index.js.map
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,52 +0,0 @@
|
||||||
{
|
|
||||||
"name": "content-type",
|
|
||||||
"version": "2.0.0",
|
|
||||||
"description": "Create and parse HTTP Content-Type header",
|
|
||||||
"keywords": [
|
|
||||||
"content-type",
|
|
||||||
"http",
|
|
||||||
"req",
|
|
||||||
"res",
|
|
||||||
"rfc7231",
|
|
||||||
"rfc9110"
|
|
||||||
],
|
|
||||||
"repository": "jshttp/content-type",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
},
|
|
||||||
"license": "MIT",
|
|
||||||
"author": "Douglas Christopher Wilson <doug@somethingdoug.com>",
|
|
||||||
"type": "commonjs",
|
|
||||||
"exports": "./dist/index.js",
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"typings": "./dist/index.d.ts",
|
|
||||||
"files": [
|
|
||||||
"dist/"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"bench": "vitest bench",
|
|
||||||
"build": "ts-scripts build",
|
|
||||||
"format": "ts-scripts format",
|
|
||||||
"prepare": "ts-scripts install && npm run build",
|
|
||||||
"specs": "ts-scripts specs",
|
|
||||||
"test": "ts-scripts test"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@borderless/ts-scripts": "^0.15.0",
|
|
||||||
"@vitest/coverage-v8": "^3.0.5",
|
|
||||||
"typescript": "^5.7.3",
|
|
||||||
"vitest": "^3.2.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"ts-scripts": {
|
|
||||||
"dist": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"project": [
|
|
||||||
"tsconfig.build.json"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
{
|
|
||||||
"name": "body-parser",
|
|
||||||
"description": "Node.js body parsing middleware",
|
|
||||||
"version": "2.3.0",
|
|
||||||
"contributors": [
|
|
||||||
"Douglas Christopher Wilson <doug@somethingdoug.com>",
|
|
||||||
"Jonathan Ong <me@jongleberry.com> (http://jongleberry.com)"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"repository": "expressjs/body-parser",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
},
|
|
||||||
"type": "commonjs",
|
|
||||||
"exports": {
|
|
||||||
".": "./index.js",
|
|
||||||
"./package.json": "./package.json",
|
|
||||||
"./json": "./lib/types/json.js",
|
|
||||||
"./raw": "./lib/types/raw.js",
|
|
||||||
"./text": "./lib/types/text.js",
|
|
||||||
"./urlencoded": "./lib/types/urlencoded.js",
|
|
||||||
"./lib/*": "./lib/*.js",
|
|
||||||
"./lib/*.js": "./lib/*.js",
|
|
||||||
"./lib/types/*": "./lib/types/*.js",
|
|
||||||
"./lib/types/*.js": "./lib/types/*.js"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"bytes": "^3.1.2",
|
|
||||||
"content-type": "^2.0.0",
|
|
||||||
"debug": "^4.4.3",
|
|
||||||
"http-errors": "^2.0.1",
|
|
||||||
"iconv-lite": "^0.7.2",
|
|
||||||
"on-finished": "^2.4.1",
|
|
||||||
"qs": "^6.15.2",
|
|
||||||
"raw-body": "^3.0.2",
|
|
||||||
"type-is": "^2.1.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"eslint": "^8.57.1",
|
|
||||||
"eslint-config-standard": "^14.1.1",
|
|
||||||
"eslint-plugin-import": "^2.32.0",
|
|
||||||
"eslint-plugin-markdown": "^3.0.1",
|
|
||||||
"eslint-plugin-node": "^11.1.0",
|
|
||||||
"eslint-plugin-promise": "^6.6.0",
|
|
||||||
"eslint-plugin-standard": "^4.1.0",
|
|
||||||
"mocha": "^11.7.6",
|
|
||||||
"nyc": "^17.1.0",
|
|
||||||
"supertest": "^7.2.2"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"lib/",
|
|
||||||
"LICENSE",
|
|
||||||
"index.js"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"lint": "eslint .",
|
|
||||||
"test": "mocha --reporter spec --check-leaks test/",
|
|
||||||
"test-ci": "nyc --reporter=lcovonly --reporter=text npm test",
|
|
||||||
"test-cov": "nyc --reporter=html --reporter=text npm test"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
3.1.2 / 2022-01-27
|
|
||||||
==================
|
|
||||||
|
|
||||||
* Fix return value for un-parsable strings
|
|
||||||
|
|
||||||
3.1.1 / 2021-11-15
|
|
||||||
==================
|
|
||||||
|
|
||||||
* Fix "thousandsSeparator" incorrecting formatting fractional part
|
|
||||||
|
|
||||||
3.1.0 / 2019-01-22
|
|
||||||
==================
|
|
||||||
|
|
||||||
* Add petabyte (`pb`) support
|
|
||||||
|
|
||||||
3.0.0 / 2017-08-31
|
|
||||||
==================
|
|
||||||
|
|
||||||
* Change "kB" to "KB" in format output
|
|
||||||
* Remove support for Node.js 0.6
|
|
||||||
* Remove support for ComponentJS
|
|
||||||
|
|
||||||
2.5.0 / 2017-03-24
|
|
||||||
==================
|
|
||||||
|
|
||||||
* Add option "unit"
|
|
||||||
|
|
||||||
2.4.0 / 2016-06-01
|
|
||||||
==================
|
|
||||||
|
|
||||||
* Add option "unitSeparator"
|
|
||||||
|
|
||||||
2.3.0 / 2016-02-15
|
|
||||||
==================
|
|
||||||
|
|
||||||
* Drop partial bytes on all parsed units
|
|
||||||
* Fix non-finite numbers to `.format` to return `null`
|
|
||||||
* Fix parsing byte string that looks like hex
|
|
||||||
* perf: hoist regular expressions
|
|
||||||
|
|
||||||
2.2.0 / 2015-11-13
|
|
||||||
==================
|
|
||||||
|
|
||||||
* add option "decimalPlaces"
|
|
||||||
* add option "fixedDecimals"
|
|
||||||
|
|
||||||
2.1.0 / 2015-05-21
|
|
||||||
==================
|
|
||||||
|
|
||||||
* add `.format` export
|
|
||||||
* add `.parse` export
|
|
||||||
|
|
||||||
2.0.2 / 2015-05-20
|
|
||||||
==================
|
|
||||||
|
|
||||||
* remove map recreation
|
|
||||||
* remove unnecessary object construction
|
|
||||||
|
|
||||||
2.0.1 / 2015-05-07
|
|
||||||
==================
|
|
||||||
|
|
||||||
* fix browserify require
|
|
||||||
* remove node.extend dependency
|
|
||||||
|
|
||||||
2.0.0 / 2015-04-12
|
|
||||||
==================
|
|
||||||
|
|
||||||
* add option "case"
|
|
||||||
* add option "thousandsSeparator"
|
|
||||||
* return "null" on invalid parse input
|
|
||||||
* support proper round-trip: bytes(bytes(num)) === num
|
|
||||||
* units no longer case sensitive when parsing
|
|
||||||
|
|
||||||
1.0.0 / 2014-05-05
|
|
||||||
==================
|
|
||||||
|
|
||||||
* add negative support. fixes #6
|
|
||||||
|
|
||||||
0.3.0 / 2014-03-19
|
|
||||||
==================
|
|
||||||
|
|
||||||
* added terabyte support
|
|
||||||
|
|
||||||
0.2.1 / 2013-04-01
|
|
||||||
==================
|
|
||||||
|
|
||||||
* add .component
|
|
||||||
|
|
||||||
0.2.0 / 2012-10-28
|
|
||||||
==================
|
|
||||||
|
|
||||||
* bytes(200).should.eql('200b')
|
|
||||||
|
|
||||||
0.1.0 / 2012-07-04
|
|
||||||
==================
|
|
||||||
|
|
||||||
* add bytes to string conversion [yields]
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
(The MIT License)
|
|
||||||
|
|
||||||
Copyright (c) 2012-2014 TJ Holowaychuk <tj@vision-media.ca>
|
|
||||||
Copyright (c) 2015 Jed Watson <jed.watson@me.com>
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of this software and associated documentation files (the
|
|
||||||
'Software'), to deal in the Software without restriction, including
|
|
||||||
without limitation the rights to use, copy, modify, merge, publish,
|
|
||||||
distribute, sublicense, and/or sell copies of the Software, and to
|
|
||||||
permit persons to whom the Software is furnished to do so, subject to
|
|
||||||
the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be
|
|
||||||
included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
||||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
||||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
||||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
||||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
# Bytes utility
|
|
||||||
|
|
||||||
[![NPM Version][npm-image]][npm-url]
|
|
||||||
[![NPM Downloads][downloads-image]][downloads-url]
|
|
||||||
[![Build Status][ci-image]][ci-url]
|
|
||||||
[![Test Coverage][coveralls-image]][coveralls-url]
|
|
||||||
|
|
||||||
Utility to parse a string bytes (ex: `1TB`) to bytes (`1099511627776`) and vice-versa.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
This is a [Node.js](https://nodejs.org/en/) module available through the
|
|
||||||
[npm registry](https://www.npmjs.com/). Installation is done using the
|
|
||||||
[`npm install` command](https://docs.npmjs.com/getting-started/installing-npm-packages-locally):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ npm install bytes
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```js
|
|
||||||
var bytes = require('bytes');
|
|
||||||
```
|
|
||||||
|
|
||||||
#### bytes(number|string value, [options]): number|string|null
|
|
||||||
|
|
||||||
Default export function. Delegates to either `bytes.format` or `bytes.parse` based on the type of `value`.
|
|
||||||
|
|
||||||
**Arguments**
|
|
||||||
|
|
||||||
| Name | Type | Description |
|
|
||||||
|---------|----------|--------------------|
|
|
||||||
| value | `number`|`string` | Number value to format or string value to parse |
|
|
||||||
| options | `Object` | Conversion options for `format` |
|
|
||||||
|
|
||||||
**Returns**
|
|
||||||
|
|
||||||
| Name | Type | Description |
|
|
||||||
|---------|------------------|-------------------------------------------------|
|
|
||||||
| results | `string`|`number`|`null` | Return null upon error. Numeric value in bytes, or string value otherwise. |
|
|
||||||
|
|
||||||
**Example**
|
|
||||||
|
|
||||||
```js
|
|
||||||
bytes(1024);
|
|
||||||
// output: '1KB'
|
|
||||||
|
|
||||||
bytes('1KB');
|
|
||||||
// output: 1024
|
|
||||||
```
|
|
||||||
|
|
||||||
#### bytes.format(number value, [options]): string|null
|
|
||||||
|
|
||||||
Format the given value in bytes into a string. If the value is negative, it is kept as such. If it is a float, it is
|
|
||||||
rounded.
|
|
||||||
|
|
||||||
**Arguments**
|
|
||||||
|
|
||||||
| Name | Type | Description |
|
|
||||||
|---------|----------|--------------------|
|
|
||||||
| value | `number` | Value in bytes |
|
|
||||||
| options | `Object` | Conversion options |
|
|
||||||
|
|
||||||
**Options**
|
|
||||||
|
|
||||||
| Property | Type | Description |
|
|
||||||
|-------------------|--------|-----------------------------------------------------------------------------------------|
|
|
||||||
| decimalPlaces | `number`|`null` | Maximum number of decimal places to include in output. Default value to `2`. |
|
|
||||||
| fixedDecimals | `boolean`|`null` | Whether to always display the maximum number of decimal places. Default value to `false` |
|
|
||||||
| thousandsSeparator | `string`|`null` | Example of values: `' '`, `','` and `'.'`... Default value to `''`. |
|
|
||||||
| unit | `string`|`null` | The unit in which the result will be returned (B/KB/MB/GB/TB). Default value to `''` (which means auto detect). |
|
|
||||||
| unitSeparator | `string`|`null` | Separator to use between number and unit. Default value to `''`. |
|
|
||||||
|
|
||||||
**Returns**
|
|
||||||
|
|
||||||
| Name | Type | Description |
|
|
||||||
|---------|------------------|-------------------------------------------------|
|
|
||||||
| results | `string`|`null` | Return null upon error. String value otherwise. |
|
|
||||||
|
|
||||||
**Example**
|
|
||||||
|
|
||||||
```js
|
|
||||||
bytes.format(1024);
|
|
||||||
// output: '1KB'
|
|
||||||
|
|
||||||
bytes.format(1000);
|
|
||||||
// output: '1000B'
|
|
||||||
|
|
||||||
bytes.format(1000, {thousandsSeparator: ' '});
|
|
||||||
// output: '1 000B'
|
|
||||||
|
|
||||||
bytes.format(1024 * 1.7, {decimalPlaces: 0});
|
|
||||||
// output: '2KB'
|
|
||||||
|
|
||||||
bytes.format(1024, {unitSeparator: ' '});
|
|
||||||
// output: '1 KB'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### bytes.parse(string|number value): number|null
|
|
||||||
|
|
||||||
Parse the string value into an integer in bytes. If no unit is given, or `value`
|
|
||||||
is a number, it is assumed the value is in bytes.
|
|
||||||
|
|
||||||
Supported units and abbreviations are as follows and are case-insensitive:
|
|
||||||
|
|
||||||
* `b` for bytes
|
|
||||||
* `kb` for kilobytes
|
|
||||||
* `mb` for megabytes
|
|
||||||
* `gb` for gigabytes
|
|
||||||
* `tb` for terabytes
|
|
||||||
* `pb` for petabytes
|
|
||||||
|
|
||||||
The units are in powers of two, not ten. This means 1kb = 1024b according to this parser.
|
|
||||||
|
|
||||||
**Arguments**
|
|
||||||
|
|
||||||
| Name | Type | Description |
|
|
||||||
|---------------|--------|--------------------|
|
|
||||||
| value | `string`|`number` | String to parse, or number in bytes. |
|
|
||||||
|
|
||||||
**Returns**
|
|
||||||
|
|
||||||
| Name | Type | Description |
|
|
||||||
|---------|-------------|-------------------------|
|
|
||||||
| results | `number`|`null` | Return null upon error. Value in bytes otherwise. |
|
|
||||||
|
|
||||||
**Example**
|
|
||||||
|
|
||||||
```js
|
|
||||||
bytes.parse('1KB');
|
|
||||||
// output: 1024
|
|
||||||
|
|
||||||
bytes.parse('1024');
|
|
||||||
// output: 1024
|
|
||||||
|
|
||||||
bytes.parse(1024);
|
|
||||||
// output: 1024
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[MIT](LICENSE)
|
|
||||||
|
|
||||||
[ci-image]: https://badgen.net/github/checks/visionmedia/bytes.js/master?label=ci
|
|
||||||
[ci-url]: https://github.com/visionmedia/bytes.js/actions?query=workflow%3Aci
|
|
||||||
[coveralls-image]: https://badgen.net/coveralls/c/github/visionmedia/bytes.js/master
|
|
||||||
[coveralls-url]: https://coveralls.io/r/visionmedia/bytes.js?branch=master
|
|
||||||
[downloads-image]: https://badgen.net/npm/dm/bytes
|
|
||||||
[downloads-url]: https://npmjs.org/package/bytes
|
|
||||||
[npm-image]: https://badgen.net/npm/v/bytes
|
|
||||||
[npm-url]: https://npmjs.org/package/bytes
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
/*!
|
|
||||||
* bytes
|
|
||||||
* Copyright(c) 2012-2014 TJ Holowaychuk
|
|
||||||
* Copyright(c) 2015 Jed Watson
|
|
||||||
* MIT Licensed
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module exports.
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
module.exports = bytes;
|
|
||||||
module.exports.format = format;
|
|
||||||
module.exports.parse = parse;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module variables.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
var formatThousandsRegExp = /\B(?=(\d{3})+(?!\d))/g;
|
|
||||||
|
|
||||||
var formatDecimalsRegExp = /(?:\.0*|(\.[^0]+)0+)$/;
|
|
||||||
|
|
||||||
var map = {
|
|
||||||
b: 1,
|
|
||||||
kb: 1 << 10,
|
|
||||||
mb: 1 << 20,
|
|
||||||
gb: 1 << 30,
|
|
||||||
tb: Math.pow(1024, 4),
|
|
||||||
pb: Math.pow(1024, 5),
|
|
||||||
};
|
|
||||||
|
|
||||||
var parseRegExp = /^((-|\+)?(\d+(?:\.\d+)?)) *(kb|mb|gb|tb|pb)$/i;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert the given value in bytes into a string or parse to string to an integer in bytes.
|
|
||||||
*
|
|
||||||
* @param {string|number} value
|
|
||||||
* @param {{
|
|
||||||
* case: [string],
|
|
||||||
* decimalPlaces: [number]
|
|
||||||
* fixedDecimals: [boolean]
|
|
||||||
* thousandsSeparator: [string]
|
|
||||||
* unitSeparator: [string]
|
|
||||||
* }} [options] bytes options.
|
|
||||||
*
|
|
||||||
* @returns {string|number|null}
|
|
||||||
*/
|
|
||||||
|
|
||||||
function bytes(value, options) {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return parse(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'number') {
|
|
||||||
return format(value, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format the given value in bytes into a string.
|
|
||||||
*
|
|
||||||
* If the value is negative, it is kept as such. If it is a float,
|
|
||||||
* it is rounded.
|
|
||||||
*
|
|
||||||
* @param {number} value
|
|
||||||
* @param {object} [options]
|
|
||||||
* @param {number} [options.decimalPlaces=2]
|
|
||||||
* @param {number} [options.fixedDecimals=false]
|
|
||||||
* @param {string} [options.thousandsSeparator=]
|
|
||||||
* @param {string} [options.unit=]
|
|
||||||
* @param {string} [options.unitSeparator=]
|
|
||||||
*
|
|
||||||
* @returns {string|null}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
function format(value, options) {
|
|
||||||
if (!Number.isFinite(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var mag = Math.abs(value);
|
|
||||||
var thousandsSeparator = (options && options.thousandsSeparator) || '';
|
|
||||||
var unitSeparator = (options && options.unitSeparator) || '';
|
|
||||||
var decimalPlaces = (options && options.decimalPlaces !== undefined) ? options.decimalPlaces : 2;
|
|
||||||
var fixedDecimals = Boolean(options && options.fixedDecimals);
|
|
||||||
var unit = (options && options.unit) || '';
|
|
||||||
|
|
||||||
if (!unit || !map[unit.toLowerCase()]) {
|
|
||||||
if (mag >= map.pb) {
|
|
||||||
unit = 'PB';
|
|
||||||
} else if (mag >= map.tb) {
|
|
||||||
unit = 'TB';
|
|
||||||
} else if (mag >= map.gb) {
|
|
||||||
unit = 'GB';
|
|
||||||
} else if (mag >= map.mb) {
|
|
||||||
unit = 'MB';
|
|
||||||
} else if (mag >= map.kb) {
|
|
||||||
unit = 'KB';
|
|
||||||
} else {
|
|
||||||
unit = 'B';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var val = value / map[unit.toLowerCase()];
|
|
||||||
var str = val.toFixed(decimalPlaces);
|
|
||||||
|
|
||||||
if (!fixedDecimals) {
|
|
||||||
str = str.replace(formatDecimalsRegExp, '$1');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (thousandsSeparator) {
|
|
||||||
str = str.split('.').map(function (s, i) {
|
|
||||||
return i === 0
|
|
||||||
? s.replace(formatThousandsRegExp, thousandsSeparator)
|
|
||||||
: s
|
|
||||||
}).join('.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return str + unitSeparator + unit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse the string value into an integer in bytes.
|
|
||||||
*
|
|
||||||
* If no unit is given, it is assumed the value is in bytes.
|
|
||||||
*
|
|
||||||
* @param {number|string} val
|
|
||||||
*
|
|
||||||
* @returns {number|null}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
function parse(val) {
|
|
||||||
if (typeof val === 'number' && !isNaN(val)) {
|
|
||||||
return val;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof val !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test if the string passed is valid
|
|
||||||
var results = parseRegExp.exec(val);
|
|
||||||
var floatValue;
|
|
||||||
var unit = 'b';
|
|
||||||
|
|
||||||
if (!results) {
|
|
||||||
// Nothing could be extracted from the given string
|
|
||||||
floatValue = parseInt(val, 10);
|
|
||||||
unit = 'b'
|
|
||||||
} else {
|
|
||||||
// Retrieve the value and the unit
|
|
||||||
floatValue = parseFloat(results[1]);
|
|
||||||
unit = results[4].toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNaN(floatValue)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.floor(map[unit] * floatValue);
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
{
|
|
||||||
"name": "bytes",
|
|
||||||
"description": "Utility to parse a string bytes to bytes and vice-versa",
|
|
||||||
"version": "3.1.2",
|
|
||||||
"author": "TJ Holowaychuk <tj@vision-media.ca> (http://tjholowaychuk.com)",
|
|
||||||
"contributors": [
|
|
||||||
"Jed Watson <jed.watson@me.com>",
|
|
||||||
"Théo FIDRY <theo.fidry@gmail.com>"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"keywords": [
|
|
||||||
"byte",
|
|
||||||
"bytes",
|
|
||||||
"utility",
|
|
||||||
"parse",
|
|
||||||
"parser",
|
|
||||||
"convert",
|
|
||||||
"converter"
|
|
||||||
],
|
|
||||||
"repository": "visionmedia/bytes.js",
|
|
||||||
"devDependencies": {
|
|
||||||
"eslint": "7.32.0",
|
|
||||||
"eslint-plugin-markdown": "2.2.1",
|
|
||||||
"mocha": "9.2.0",
|
|
||||||
"nyc": "15.1.0"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"History.md",
|
|
||||||
"LICENSE",
|
|
||||||
"Readme.md",
|
|
||||||
"index.js"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"lint": "eslint .",
|
|
||||||
"test": "mocha --check-leaks --reporter spec",
|
|
||||||
"test-ci": "nyc --reporter=lcov --reporter=text npm test",
|
|
||||||
"test-cov": "nyc --reporter=html --reporter=text npm test"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
{
|
|
||||||
"root": true,
|
|
||||||
|
|
||||||
"extends": "@ljharb",
|
|
||||||
|
|
||||||
"rules": {
|
|
||||||
"func-name-matching": 0,
|
|
||||||
"id-length": 0,
|
|
||||||
"new-cap": [2, {
|
|
||||||
"capIsNewExceptions": [
|
|
||||||
"GetIntrinsic",
|
|
||||||
],
|
|
||||||
}],
|
|
||||||
"no-extra-parens": 0,
|
|
||||||
"no-magic-numbers": 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
# These are supported funding model platforms
|
|
||||||
|
|
||||||
github: [ljharb]
|
|
||||||
patreon: # Replace with a single Patreon username
|
|
||||||
open_collective: # Replace with a single Open Collective username
|
|
||||||
ko_fi: # Replace with a single Ko-fi username
|
|
||||||
tidelift: npm/call-bind-apply-helpers
|
|
||||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
|
||||||
liberapay: # Replace with a single Liberapay username
|
|
||||||
issuehunt: # Replace with a single IssueHunt username
|
|
||||||
otechie: # Replace with a single Otechie username
|
|
||||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"all": true,
|
|
||||||
"check-coverage": false,
|
|
||||||
"reporter": ["text-summary", "text", "html", "json"],
|
|
||||||
"exclude": [
|
|
||||||
"coverage",
|
|
||||||
"test"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
||||||
|
|
||||||
## [v1.0.2](https://github.com/ljharb/call-bind-apply-helpers/compare/v1.0.1...v1.0.2) - 2025-02-12
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
|
|
||||||
- [types] improve inferred types [`e6f9586`](https://github.com/ljharb/call-bind-apply-helpers/commit/e6f95860a3c72879cb861a858cdfb8138fbedec1)
|
|
||||||
- [Dev Deps] update `@arethetypeswrong/cli`, `@ljharb/tsconfig`, `@types/tape`, `es-value-fixtures`, `for-each`, `has-strict-mode`, `object-inspect` [`e43d540`](https://github.com/ljharb/call-bind-apply-helpers/commit/e43d5409f97543bfbb11f345d47d8ce4e066d8c1)
|
|
||||||
|
|
||||||
## [v1.0.1](https://github.com/ljharb/call-bind-apply-helpers/compare/v1.0.0...v1.0.1) - 2024-12-08
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
|
|
||||||
- [types] `reflectApply`: fix types [`4efc396`](https://github.com/ljharb/call-bind-apply-helpers/commit/4efc3965351a4f02cc55e836fa391d3d11ef2ef8)
|
|
||||||
- [Fix] `reflectApply`: oops, Reflect is not a function [`83cc739`](https://github.com/ljharb/call-bind-apply-helpers/commit/83cc7395de6b79b7730bdf092f1436f0b1263c75)
|
|
||||||
- [Dev Deps] update `@arethetypeswrong/cli` [`80bd5d3`](https://github.com/ljharb/call-bind-apply-helpers/commit/80bd5d3ae58b4f6b6995ce439dd5a1bcb178a940)
|
|
||||||
|
|
||||||
## v1.0.0 - 2024-12-05
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
|
|
||||||
- Initial implementation, tests, readme [`7879629`](https://github.com/ljharb/call-bind-apply-helpers/commit/78796290f9b7430c9934d6f33d94ae9bc89fce04)
|
|
||||||
- Initial commit [`3f1dc16`](https://github.com/ljharb/call-bind-apply-helpers/commit/3f1dc164afc43285631b114a5f9dd9137b2b952f)
|
|
||||||
- npm init [`081df04`](https://github.com/ljharb/call-bind-apply-helpers/commit/081df048c312fcee400922026f6e97281200a603)
|
|
||||||
- Only apps should have lockfiles [`5b9ca0f`](https://github.com/ljharb/call-bind-apply-helpers/commit/5b9ca0fe8101ebfaf309c549caac4e0a017ed930)
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2024 Jordan Harband
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
# call-bind-apply-helpers <sup>[![Version Badge][npm-version-svg]][package-url]</sup>
|
|
||||||
|
|
||||||
[![github actions][actions-image]][actions-url]
|
|
||||||
[![coverage][codecov-image]][codecov-url]
|
|
||||||
[![dependency status][deps-svg]][deps-url]
|
|
||||||
[![dev dependency status][dev-deps-svg]][dev-deps-url]
|
|
||||||
[![License][license-image]][license-url]
|
|
||||||
[![Downloads][downloads-image]][downloads-url]
|
|
||||||
|
|
||||||
[![npm badge][npm-badge-png]][package-url]
|
|
||||||
|
|
||||||
Helper functions around Function call/apply/bind, for use in `call-bind`.
|
|
||||||
|
|
||||||
The only packages that should likely ever use this package directly are `call-bind` and `get-intrinsic`.
|
|
||||||
Please use `call-bind` unless you have a very good reason not to.
|
|
||||||
|
|
||||||
## Getting started
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm install --save call-bind-apply-helpers
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage/Examples
|
|
||||||
|
|
||||||
```js
|
|
||||||
const assert = require('assert');
|
|
||||||
const callBindBasic = require('call-bind-apply-helpers');
|
|
||||||
|
|
||||||
function f(a, b) {
|
|
||||||
assert.equal(this, 1);
|
|
||||||
assert.equal(a, 2);
|
|
||||||
assert.equal(b, 3);
|
|
||||||
assert.equal(arguments.length, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fBound = callBindBasic([f, 1]);
|
|
||||||
|
|
||||||
delete Function.prototype.call;
|
|
||||||
delete Function.prototype.bind;
|
|
||||||
|
|
||||||
fBound(2, 3);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
Clone the repo, `npm install`, and run `npm test`
|
|
||||||
|
|
||||||
[package-url]: https://npmjs.org/package/call-bind-apply-helpers
|
|
||||||
[npm-version-svg]: https://versionbadg.es/ljharb/call-bind-apply-helpers.svg
|
|
||||||
[deps-svg]: https://david-dm.org/ljharb/call-bind-apply-helpers.svg
|
|
||||||
[deps-url]: https://david-dm.org/ljharb/call-bind-apply-helpers
|
|
||||||
[dev-deps-svg]: https://david-dm.org/ljharb/call-bind-apply-helpers/dev-status.svg
|
|
||||||
[dev-deps-url]: https://david-dm.org/ljharb/call-bind-apply-helpers#info=devDependencies
|
|
||||||
[npm-badge-png]: https://nodei.co/npm/call-bind-apply-helpers.png?downloads=true&stars=true
|
|
||||||
[license-image]: https://img.shields.io/npm/l/call-bind-apply-helpers.svg
|
|
||||||
[license-url]: LICENSE
|
|
||||||
[downloads-image]: https://img.shields.io/npm/dm/call-bind-apply-helpers.svg
|
|
||||||
[downloads-url]: https://npm-stat.com/charts.html?package=call-bind-apply-helpers
|
|
||||||
[codecov-image]: https://codecov.io/gh/ljharb/call-bind-apply-helpers/branch/main/graphs/badge.svg
|
|
||||||
[codecov-url]: https://app.codecov.io/gh/ljharb/call-bind-apply-helpers/
|
|
||||||
[actions-image]: https://img.shields.io/endpoint?url=https://github-actions-badge-u3jn4tfpocch.runkit.sh/ljharb/call-bind-apply-helpers
|
|
||||||
[actions-url]: https://github.com/ljharb/call-bind-apply-helpers/actions
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export = Reflect.apply;
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
var bind = require('function-bind');
|
|
||||||
|
|
||||||
var $apply = require('./functionApply');
|
|
||||||
var $call = require('./functionCall');
|
|
||||||
var $reflectApply = require('./reflectApply');
|
|
||||||
|
|
||||||
/** @type {import('./actualApply')} */
|
|
||||||
module.exports = $reflectApply || bind.call($call, $apply);
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import actualApply from './actualApply';
|
|
||||||
|
|
||||||
type TupleSplitHead<T extends any[], N extends number> = T['length'] extends N
|
|
||||||
? T
|
|
||||||
: T extends [...infer R, any]
|
|
||||||
? TupleSplitHead<R, N>
|
|
||||||
: never
|
|
||||||
|
|
||||||
type TupleSplitTail<T, N extends number, O extends any[] = []> = O['length'] extends N
|
|
||||||
? T
|
|
||||||
: T extends [infer F, ...infer R]
|
|
||||||
? TupleSplitTail<[...R], N, [...O, F]>
|
|
||||||
: never
|
|
||||||
|
|
||||||
type TupleSplit<T extends any[], N extends number> = [TupleSplitHead<T, N>, TupleSplitTail<T, N>]
|
|
||||||
|
|
||||||
declare function applyBind(...args: TupleSplit<Parameters<typeof actualApply>, 2>[1]): ReturnType<typeof actualApply>;
|
|
||||||
|
|
||||||
export = applyBind;
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
var bind = require('function-bind');
|
|
||||||
var $apply = require('./functionApply');
|
|
||||||
var actualApply = require('./actualApply');
|
|
||||||
|
|
||||||
/** @type {import('./applyBind')} */
|
|
||||||
module.exports = function applyBind() {
|
|
||||||
return actualApply(bind, $apply, arguments);
|
|
||||||
};
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export = Function.prototype.apply;
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/** @type {import('./functionApply')} */
|
|
||||||
module.exports = Function.prototype.apply;
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export = Function.prototype.call;
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/** @type {import('./functionCall')} */
|
|
||||||
module.exports = Function.prototype.call;
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
type RemoveFromTuple<
|
|
||||||
Tuple extends readonly unknown[],
|
|
||||||
RemoveCount extends number,
|
|
||||||
Index extends 1[] = []
|
|
||||||
> = Index["length"] extends RemoveCount
|
|
||||||
? Tuple
|
|
||||||
: Tuple extends [infer First, ...infer Rest]
|
|
||||||
? RemoveFromTuple<Rest, RemoveCount, [...Index, 1]>
|
|
||||||
: Tuple;
|
|
||||||
|
|
||||||
type ConcatTuples<
|
|
||||||
Prefix extends readonly unknown[],
|
|
||||||
Suffix extends readonly unknown[]
|
|
||||||
> = [...Prefix, ...Suffix];
|
|
||||||
|
|
||||||
type ExtractFunctionParams<T> = T extends (this: infer TThis, ...args: infer P extends readonly unknown[]) => infer R
|
|
||||||
? { thisArg: TThis; params: P; returnType: R }
|
|
||||||
: never;
|
|
||||||
|
|
||||||
type BindFunction<
|
|
||||||
T extends (this: any, ...args: any[]) => any,
|
|
||||||
TThis,
|
|
||||||
TBoundArgs extends readonly unknown[],
|
|
||||||
ReceiverBound extends boolean
|
|
||||||
> = ExtractFunctionParams<T> extends {
|
|
||||||
thisArg: infer OrigThis;
|
|
||||||
params: infer P extends readonly unknown[];
|
|
||||||
returnType: infer R;
|
|
||||||
}
|
|
||||||
? ReceiverBound extends true
|
|
||||||
? (...args: RemoveFromTuple<P, Extract<TBoundArgs["length"], number>>) => R extends [OrigThis, ...infer Rest]
|
|
||||||
? [TThis, ...Rest] // Replace `this` with `thisArg`
|
|
||||||
: R
|
|
||||||
: <U, RemainingArgs extends RemoveFromTuple<P, Extract<TBoundArgs["length"], number>>>(
|
|
||||||
thisArg: U,
|
|
||||||
...args: RemainingArgs
|
|
||||||
) => R extends [OrigThis, ...infer Rest]
|
|
||||||
? [U, ...ConcatTuples<TBoundArgs, Rest>] // Preserve bound args in return type
|
|
||||||
: R
|
|
||||||
: never;
|
|
||||||
|
|
||||||
declare function callBind<
|
|
||||||
const T extends (this: any, ...args: any[]) => any,
|
|
||||||
Extracted extends ExtractFunctionParams<T>,
|
|
||||||
const TBoundArgs extends Partial<Extracted["params"]> & readonly unknown[],
|
|
||||||
const TThis extends Extracted["thisArg"]
|
|
||||||
>(
|
|
||||||
args: [fn: T, thisArg: TThis, ...boundArgs: TBoundArgs]
|
|
||||||
): BindFunction<T, TThis, TBoundArgs, true>;
|
|
||||||
|
|
||||||
declare function callBind<
|
|
||||||
const T extends (this: any, ...args: any[]) => any,
|
|
||||||
Extracted extends ExtractFunctionParams<T>,
|
|
||||||
const TBoundArgs extends Partial<Extracted["params"]> & readonly unknown[]
|
|
||||||
>(
|
|
||||||
args: [fn: T, ...boundArgs: TBoundArgs]
|
|
||||||
): BindFunction<T, Extracted["thisArg"], TBoundArgs, false>;
|
|
||||||
|
|
||||||
declare function callBind<const TArgs extends readonly unknown[]>(
|
|
||||||
args: [fn: Exclude<TArgs[0], Function>, ...rest: TArgs]
|
|
||||||
): never;
|
|
||||||
|
|
||||||
// export as namespace callBind;
|
|
||||||
export = callBind;
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
var bind = require('function-bind');
|
|
||||||
var $TypeError = require('es-errors/type');
|
|
||||||
|
|
||||||
var $call = require('./functionCall');
|
|
||||||
var $actualApply = require('./actualApply');
|
|
||||||
|
|
||||||
/** @type {(args: [Function, thisArg?: unknown, ...args: unknown[]]) => Function} TODO FIXME, find a way to use import('.') */
|
|
||||||
module.exports = function callBindBasic(args) {
|
|
||||||
if (args.length < 1 || typeof args[0] !== 'function') {
|
|
||||||
throw new $TypeError('a function is required');
|
|
||||||
}
|
|
||||||
return $actualApply(bind, $call, args);
|
|
||||||
};
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
{
|
|
||||||
"name": "call-bind-apply-helpers",
|
|
||||||
"version": "1.0.2",
|
|
||||||
"description": "Helper functions around Function call/apply/bind, for use in `call-bind`",
|
|
||||||
"main": "index.js",
|
|
||||||
"exports": {
|
|
||||||
".": "./index.js",
|
|
||||||
"./actualApply": "./actualApply.js",
|
|
||||||
"./applyBind": "./applyBind.js",
|
|
||||||
"./functionApply": "./functionApply.js",
|
|
||||||
"./functionCall": "./functionCall.js",
|
|
||||||
"./reflectApply": "./reflectApply.js",
|
|
||||||
"./package.json": "./package.json"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"prepack": "npmignore --auto --commentLines=auto",
|
|
||||||
"prepublish": "not-in-publish || npm run prepublishOnly",
|
|
||||||
"prepublishOnly": "safe-publish-latest",
|
|
||||||
"prelint": "evalmd README.md",
|
|
||||||
"lint": "eslint --ext=.js,.mjs .",
|
|
||||||
"postlint": "tsc -p . && attw -P",
|
|
||||||
"pretest": "npm run lint",
|
|
||||||
"tests-only": "nyc tape 'test/**/*.js'",
|
|
||||||
"test": "npm run tests-only",
|
|
||||||
"posttest": "npx npm@'>=10.2' audit --production",
|
|
||||||
"version": "auto-changelog && git add CHANGELOG.md",
|
|
||||||
"postversion": "auto-changelog && git add CHANGELOG.md && git commit --no-edit --amend && git tag -f \"v$(node -e \"console.log(require('./package.json').version)\")\""
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "git+https://github.com/ljharb/call-bind-apply-helpers.git"
|
|
||||||
},
|
|
||||||
"author": "Jordan Harband <ljharb@gmail.com>",
|
|
||||||
"license": "MIT",
|
|
||||||
"bugs": {
|
|
||||||
"url": "https://github.com/ljharb/call-bind-apply-helpers/issues"
|
|
||||||
},
|
|
||||||
"homepage": "https://github.com/ljharb/call-bind-apply-helpers#readme",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"function-bind": "^1.1.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@arethetypeswrong/cli": "^0.17.3",
|
|
||||||
"@ljharb/eslint-config": "^21.1.1",
|
|
||||||
"@ljharb/tsconfig": "^0.2.3",
|
|
||||||
"@types/for-each": "^0.3.3",
|
|
||||||
"@types/function-bind": "^1.1.10",
|
|
||||||
"@types/object-inspect": "^1.13.0",
|
|
||||||
"@types/tape": "^5.8.1",
|
|
||||||
"auto-changelog": "^2.5.0",
|
|
||||||
"encoding": "^0.1.13",
|
|
||||||
"es-value-fixtures": "^1.7.1",
|
|
||||||
"eslint": "=8.8.0",
|
|
||||||
"evalmd": "^0.0.19",
|
|
||||||
"for-each": "^0.3.5",
|
|
||||||
"has-strict-mode": "^1.1.0",
|
|
||||||
"in-publish": "^2.0.1",
|
|
||||||
"npmignore": "^0.3.1",
|
|
||||||
"nyc": "^10.3.2",
|
|
||||||
"object-inspect": "^1.13.4",
|
|
||||||
"safe-publish-latest": "^2.0.0",
|
|
||||||
"tape": "^5.9.0",
|
|
||||||
"typescript": "next"
|
|
||||||
},
|
|
||||||
"testling": {
|
|
||||||
"files": "test/index.js"
|
|
||||||
},
|
|
||||||
"auto-changelog": {
|
|
||||||
"output": "CHANGELOG.md",
|
|
||||||
"template": "keepachangelog",
|
|
||||||
"unreleased": false,
|
|
||||||
"commitLimit": false,
|
|
||||||
"backfillLimit": false,
|
|
||||||
"hideCredit": true
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"ignore": [
|
|
||||||
".github/workflows"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
declare const reflectApply: false | typeof Reflect.apply;
|
|
||||||
|
|
||||||
export = reflectApply;
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/** @type {import('./reflectApply')} */
|
|
||||||
module.exports = typeof Reflect !== 'undefined' && Reflect && Reflect.apply;
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
var callBind = require('../');
|
|
||||||
var hasStrictMode = require('has-strict-mode')();
|
|
||||||
var forEach = require('for-each');
|
|
||||||
var inspect = require('object-inspect');
|
|
||||||
var v = require('es-value-fixtures');
|
|
||||||
|
|
||||||
var test = require('tape');
|
|
||||||
|
|
||||||
test('callBindBasic', function (t) {
|
|
||||||
forEach(v.nonFunctions, function (nonFunction) {
|
|
||||||
t['throws'](
|
|
||||||
// @ts-expect-error
|
|
||||||
function () { callBind([nonFunction]); },
|
|
||||||
TypeError,
|
|
||||||
inspect(nonFunction) + ' is not a function'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
var sentinel = { sentinel: true };
|
|
||||||
/** @type {<T, A extends number, B extends number>(this: T, a: A, b: B) => [T | undefined, A, B]} */
|
|
||||||
var func = function (a, b) {
|
|
||||||
// eslint-disable-next-line no-invalid-this
|
|
||||||
return [!hasStrictMode && this === global ? undefined : this, a, b];
|
|
||||||
};
|
|
||||||
t.equal(func.length, 2, 'original function length is 2');
|
|
||||||
|
|
||||||
/** type {(thisArg: unknown, a: number, b: number) => [unknown, number, number]} */
|
|
||||||
var bound = callBind([func]);
|
|
||||||
/** type {((a: number, b: number) => [typeof sentinel, typeof a, typeof b])} */
|
|
||||||
var boundR = callBind([func, sentinel]);
|
|
||||||
/** type {((b: number) => [typeof sentinel, number, typeof b])} */
|
|
||||||
var boundArg = callBind([func, sentinel, /** @type {const} */ (1)]);
|
|
||||||
|
|
||||||
// @ts-expect-error
|
|
||||||
t.deepEqual(bound(), [undefined, undefined, undefined], 'bound func with no args');
|
|
||||||
|
|
||||||
// @ts-expect-error
|
|
||||||
t.deepEqual(func(), [undefined, undefined, undefined], 'unbound func with too few args');
|
|
||||||
// @ts-expect-error
|
|
||||||
t.deepEqual(bound(1, 2), [hasStrictMode ? 1 : Object(1), 2, undefined], 'bound func too few args');
|
|
||||||
// @ts-expect-error
|
|
||||||
t.deepEqual(boundR(), [sentinel, undefined, undefined], 'bound func with receiver, with too few args');
|
|
||||||
// @ts-expect-error
|
|
||||||
t.deepEqual(boundArg(), [sentinel, 1, undefined], 'bound func with receiver and arg, with too few args');
|
|
||||||
|
|
||||||
t.deepEqual(func(1, 2), [undefined, 1, 2], 'unbound func with right args');
|
|
||||||
t.deepEqual(bound(1, 2, 3), [hasStrictMode ? 1 : Object(1), 2, 3], 'bound func with right args');
|
|
||||||
t.deepEqual(boundR(1, 2), [sentinel, 1, 2], 'bound func with receiver, with right args');
|
|
||||||
t.deepEqual(boundArg(2), [sentinel, 1, 2], 'bound func with receiver and arg, with right arg');
|
|
||||||
|
|
||||||
// @ts-expect-error
|
|
||||||
t.deepEqual(func(1, 2, 3), [undefined, 1, 2], 'unbound func with too many args');
|
|
||||||
// @ts-expect-error
|
|
||||||
t.deepEqual(bound(1, 2, 3, 4), [hasStrictMode ? 1 : Object(1), 2, 3], 'bound func with too many args');
|
|
||||||
// @ts-expect-error
|
|
||||||
t.deepEqual(boundR(1, 2, 3), [sentinel, 1, 2], 'bound func with receiver, with too many args');
|
|
||||||
// @ts-expect-error
|
|
||||||
t.deepEqual(boundArg(2, 3), [sentinel, 1, 2], 'bound func with receiver and arg, with too many args');
|
|
||||||
|
|
||||||
t.end();
|
|
||||||
});
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"extends": "@ljharb/tsconfig",
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "es2021",
|
|
||||||
},
|
|
||||||
"exclude": [
|
|
||||||
"coverage",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"root": true,
|
|
||||||
|
|
||||||
"extends": "@ljharb",
|
|
||||||
|
|
||||||
"rules": {
|
|
||||||
"new-cap": [2, {
|
|
||||||
"capIsNewExceptions": [
|
|
||||||
"GetIntrinsic",
|
|
||||||
],
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
# These are supported funding model platforms
|
|
||||||
|
|
||||||
github: [ljharb]
|
|
||||||
patreon: # Replace with a single Patreon username
|
|
||||||
open_collective: # Replace with a single Open Collective username
|
|
||||||
ko_fi: # Replace with a single Ko-fi username
|
|
||||||
tidelift: npm/call-bound
|
|
||||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
|
||||||
liberapay: # Replace with a single Liberapay username
|
|
||||||
issuehunt: # Replace with a single IssueHunt username
|
|
||||||
otechie: # Replace with a single Otechie username
|
|
||||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"all": true,
|
|
||||||
"check-coverage": false,
|
|
||||||
"reporter": ["text-summary", "text", "html", "json"],
|
|
||||||
"exclude": [
|
|
||||||
"coverage",
|
|
||||||
"test"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
||||||
|
|
||||||
## [v1.0.4](https://github.com/ljharb/call-bound/compare/v1.0.3...v1.0.4) - 2025-03-03
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
|
|
||||||
- [types] improve types [`e648922`](https://github.com/ljharb/call-bound/commit/e6489222a9e54f350fbf952ceabe51fd8b6027ff)
|
|
||||||
- [Dev Deps] update `@arethetypeswrong/cli`, `@ljharb/tsconfig`, `@types/tape`, `es-value-fixtures`, `for-each`, `has-strict-mode`, `object-inspect` [`a42a5eb`](https://github.com/ljharb/call-bound/commit/a42a5ebe6c1b54fcdc7997c7dc64fdca9e936719)
|
|
||||||
- [Deps] update `call-bind-apply-helpers`, `get-intrinsic` [`f529eac`](https://github.com/ljharb/call-bound/commit/f529eac132404c17156bbc23ab2297a25d0f20b8)
|
|
||||||
|
|
||||||
## [v1.0.3](https://github.com/ljharb/call-bound/compare/v1.0.2...v1.0.3) - 2024-12-15
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
|
|
||||||
- [Refactor] use `call-bind-apply-helpers` instead of `call-bind` [`5e0b134`](https://github.com/ljharb/call-bound/commit/5e0b13496df14fb7d05dae9412f088da8d3f75be)
|
|
||||||
- [Deps] update `get-intrinsic` [`41fc967`](https://github.com/ljharb/call-bound/commit/41fc96732a22c7b7e8f381f93ccc54bb6293be2e)
|
|
||||||
- [readme] fix example [`79a0137`](https://github.com/ljharb/call-bound/commit/79a0137723f7c6d09c9c05452bbf8d5efb5d6e49)
|
|
||||||
- [meta] add `sideEffects` flag [`08b07be`](https://github.com/ljharb/call-bound/commit/08b07be7f1c03f67dc6f3cdaf0906259771859f7)
|
|
||||||
|
|
||||||
## [v1.0.2](https://github.com/ljharb/call-bound/compare/v1.0.1...v1.0.2) - 2024-12-10
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
|
|
||||||
- [Dev Deps] update `@arethetypeswrong/cli`, `@ljharb/tsconfig`, `gopd` [`e6a5ffe`](https://github.com/ljharb/call-bound/commit/e6a5ffe849368fe4f74dfd6cdeca1b9baa39e8d5)
|
|
||||||
- [Deps] update `call-bind`, `get-intrinsic` [`2aeb5b5`](https://github.com/ljharb/call-bound/commit/2aeb5b521dc2b2683d1345c753ea1161de2d1c14)
|
|
||||||
- [types] improve return type [`1a0c9fe`](https://github.com/ljharb/call-bound/commit/1a0c9fe3114471e7ca1f57d104e2efe713bb4871)
|
|
||||||
|
|
||||||
## v1.0.1 - 2024-12-05
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
|
|
||||||
- Initial implementation, tests, readme, types [`6d94121`](https://github.com/ljharb/call-bound/commit/6d94121a9243602e506334069f7a03189fe3363d)
|
|
||||||
- Initial commit [`0eae867`](https://github.com/ljharb/call-bound/commit/0eae867334ea025c33e6e91cdecfc9df96680cf9)
|
|
||||||
- npm init [`71b2479`](https://github.com/ljharb/call-bound/commit/71b2479c6723e0b7d91a6b663613067e98b7b275)
|
|
||||||
- Only apps should have lockfiles [`c3754a9`](https://github.com/ljharb/call-bound/commit/c3754a949b7f9132b47e2d18c1729889736741eb)
|
|
||||||
- [actions] skip `npm ls` in node < 10 [`74275a5`](https://github.com/ljharb/call-bound/commit/74275a5186b8caf6309b6b97472bdcb0df4683a8)
|
|
||||||
- [Dev Deps] add missing peer dep [`1354de8`](https://github.com/ljharb/call-bound/commit/1354de8679413e4ae9c523d85f76fa7a5e032d97)
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2024 Jordan Harband
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
# call-bound <sup>[![Version Badge][npm-version-svg]][package-url]</sup>
|
|
||||||
|
|
||||||
[![github actions][actions-image]][actions-url]
|
|
||||||
[![coverage][codecov-image]][codecov-url]
|
|
||||||
[![dependency status][deps-svg]][deps-url]
|
|
||||||
[![dev dependency status][dev-deps-svg]][dev-deps-url]
|
|
||||||
[![License][license-image]][license-url]
|
|
||||||
[![Downloads][downloads-image]][downloads-url]
|
|
||||||
|
|
||||||
[![npm badge][npm-badge-png]][package-url]
|
|
||||||
|
|
||||||
Robust call-bound JavaScript intrinsics, using `call-bind` and `get-intrinsic`.
|
|
||||||
|
|
||||||
## Getting started
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm install --save call-bound
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage/Examples
|
|
||||||
|
|
||||||
```js
|
|
||||||
const assert = require('assert');
|
|
||||||
const callBound = require('call-bound');
|
|
||||||
|
|
||||||
const slice = callBound('Array.prototype.slice');
|
|
||||||
|
|
||||||
delete Function.prototype.call;
|
|
||||||
delete Function.prototype.bind;
|
|
||||||
delete Array.prototype.slice;
|
|
||||||
|
|
||||||
assert.deepEqual(slice([1, 2, 3, 4], 1, -1), [2, 3]);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
Clone the repo, `npm install`, and run `npm test`
|
|
||||||
|
|
||||||
[package-url]: https://npmjs.org/package/call-bound
|
|
||||||
[npm-version-svg]: https://versionbadg.es/ljharb/call-bound.svg
|
|
||||||
[deps-svg]: https://david-dm.org/ljharb/call-bound.svg
|
|
||||||
[deps-url]: https://david-dm.org/ljharb/call-bound
|
|
||||||
[dev-deps-svg]: https://david-dm.org/ljharb/call-bound/dev-status.svg
|
|
||||||
[dev-deps-url]: https://david-dm.org/ljharb/call-bound#info=devDependencies
|
|
||||||
[npm-badge-png]: https://nodei.co/npm/call-bound.png?downloads=true&stars=true
|
|
||||||
[license-image]: https://img.shields.io/npm/l/call-bound.svg
|
|
||||||
[license-url]: LICENSE
|
|
||||||
[downloads-image]: https://img.shields.io/npm/dm/call-bound.svg
|
|
||||||
[downloads-url]: https://npm-stat.com/charts.html?package=call-bound
|
|
||||||
[codecov-image]: https://codecov.io/gh/ljharb/call-bound/branch/main/graphs/badge.svg
|
|
||||||
[codecov-url]: https://app.codecov.io/gh/ljharb/call-bound/
|
|
||||||
[actions-image]: https://img.shields.io/endpoint?url=https://github-actions-badge-u3jn4tfpocch.runkit.sh/ljharb/call-bound
|
|
||||||
[actions-url]: https://github.com/ljharb/call-bound/actions
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
type Intrinsic = typeof globalThis;
|
|
||||||
|
|
||||||
type IntrinsicName = keyof Intrinsic | `%${keyof Intrinsic}%`;
|
|
||||||
|
|
||||||
type IntrinsicPath = IntrinsicName | `${StripPercents<IntrinsicName>}.${string}` | `%${StripPercents<IntrinsicName>}.${string}%`;
|
|
||||||
|
|
||||||
type AllowMissing = boolean;
|
|
||||||
|
|
||||||
type StripPercents<T extends string> = T extends `%${infer U}%` ? U : T;
|
|
||||||
|
|
||||||
type BindMethodPrecise<F> =
|
|
||||||
F extends (this: infer This, ...args: infer Args) => infer R
|
|
||||||
? (obj: This, ...args: Args) => R
|
|
||||||
: F extends {
|
|
||||||
(this: infer This1, ...args: infer Args1): infer R1;
|
|
||||||
(this: infer This2, ...args: infer Args2): infer R2
|
|
||||||
}
|
|
||||||
? {
|
|
||||||
(obj: This1, ...args: Args1): R1;
|
|
||||||
(obj: This2, ...args: Args2): R2
|
|
||||||
}
|
|
||||||
: never
|
|
||||||
|
|
||||||
// Extract method type from a prototype
|
|
||||||
type GetPrototypeMethod<T extends keyof typeof globalThis, M extends string> =
|
|
||||||
(typeof globalThis)[T] extends { prototype: any }
|
|
||||||
? M extends keyof (typeof globalThis)[T]['prototype']
|
|
||||||
? (typeof globalThis)[T]['prototype'][M]
|
|
||||||
: never
|
|
||||||
: never
|
|
||||||
|
|
||||||
// Get static property/method
|
|
||||||
type GetStaticMember<T extends keyof typeof globalThis, P extends string> =
|
|
||||||
P extends keyof (typeof globalThis)[T] ? (typeof globalThis)[T][P] : never
|
|
||||||
|
|
||||||
// Type that maps string path to actual bound function or value with better precision
|
|
||||||
type BoundIntrinsic<S extends string> =
|
|
||||||
S extends `${infer Obj}.prototype.${infer Method}`
|
|
||||||
? Obj extends keyof typeof globalThis
|
|
||||||
? BindMethodPrecise<GetPrototypeMethod<Obj, Method & string>>
|
|
||||||
: unknown
|
|
||||||
: S extends `${infer Obj}.${infer Prop}`
|
|
||||||
? Obj extends keyof typeof globalThis
|
|
||||||
? GetStaticMember<Obj, Prop & string>
|
|
||||||
: unknown
|
|
||||||
: unknown
|
|
||||||
|
|
||||||
declare function arraySlice<T>(array: readonly T[], start?: number, end?: number): T[];
|
|
||||||
declare function arraySlice<T>(array: ArrayLike<T>, start?: number, end?: number): T[];
|
|
||||||
declare function arraySlice<T>(array: IArguments, start?: number, end?: number): T[];
|
|
||||||
|
|
||||||
// Special cases for methods that need explicit typing
|
|
||||||
interface SpecialCases {
|
|
||||||
'%Object.prototype.isPrototypeOf%': (thisArg: {}, obj: unknown) => boolean;
|
|
||||||
'%String.prototype.replace%': {
|
|
||||||
(str: string, searchValue: string | RegExp, replaceValue: string): string;
|
|
||||||
(str: string, searchValue: string | RegExp, replacer: (substring: string, ...args: any[]) => string): string
|
|
||||||
};
|
|
||||||
'%Object.prototype.toString%': (obj: {}) => string;
|
|
||||||
'%Object.prototype.hasOwnProperty%': (obj: {}, v: PropertyKey) => boolean;
|
|
||||||
'%Array.prototype.slice%': typeof arraySlice;
|
|
||||||
'%Array.prototype.map%': <T, U>(array: readonly T[], callbackfn: (value: T, index: number, array: readonly T[]) => U, thisArg?: any) => U[];
|
|
||||||
'%Array.prototype.filter%': <T>(array: readonly T[], predicate: (value: T, index: number, array: readonly T[]) => unknown, thisArg?: any) => T[];
|
|
||||||
'%Array.prototype.indexOf%': <T>(array: readonly T[], searchElement: T, fromIndex?: number) => number;
|
|
||||||
'%Function.prototype.apply%': <T, A extends any[], R>(fn: (...args: A) => R, thisArg: any, args: A) => R;
|
|
||||||
'%Function.prototype.call%': <T, A extends any[], R>(fn: (...args: A) => R, thisArg: any, ...args: A) => R;
|
|
||||||
'%Function.prototype.bind%': <T, A extends any[], R>(fn: (...args: A) => R, thisArg: any, ...args: A) => (...remainingArgs: A) => R;
|
|
||||||
'%Promise.prototype.then%': {
|
|
||||||
<T, R>(promise: Promise<T>, onfulfilled: (value: T) => R | PromiseLike<R>): Promise<R>;
|
|
||||||
<T, R>(promise: Promise<T>, onfulfilled: ((value: T) => R | PromiseLike<R>) | undefined | null, onrejected: (reason: any) => R | PromiseLike<R>): Promise<R>;
|
|
||||||
};
|
|
||||||
'%RegExp.prototype.test%': (regexp: RegExp, str: string) => boolean;
|
|
||||||
'%RegExp.prototype.exec%': (regexp: RegExp, str: string) => RegExpExecArray | null;
|
|
||||||
'%Error.prototype.toString%': (error: Error) => string;
|
|
||||||
'%TypeError.prototype.toString%': (error: TypeError) => string;
|
|
||||||
'%String.prototype.split%': (
|
|
||||||
obj: unknown,
|
|
||||||
splitter: string | RegExp | {
|
|
||||||
[Symbol.split](string: string, limit?: number): string[];
|
|
||||||
},
|
|
||||||
limit?: number | undefined
|
|
||||||
) => string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a bound function for a prototype method, or a value for a static property.
|
|
||||||
*
|
|
||||||
* @param name - The name of the intrinsic (e.g. 'Array.prototype.slice')
|
|
||||||
* @param {AllowMissing} [allowMissing] - Whether to allow missing intrinsics (default: false)
|
|
||||||
*/
|
|
||||||
declare function callBound<K extends keyof SpecialCases | StripPercents<keyof SpecialCases>, S extends IntrinsicPath>(name: K, allowMissing?: AllowMissing): SpecialCases[`%${StripPercents<K>}%`];
|
|
||||||
declare function callBound<K extends keyof SpecialCases | StripPercents<keyof SpecialCases>, S extends IntrinsicPath>(name: S, allowMissing?: AllowMissing): BoundIntrinsic<S>;
|
|
||||||
|
|
||||||
export = callBound;
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
var GetIntrinsic = require('get-intrinsic');
|
|
||||||
|
|
||||||
var callBindBasic = require('call-bind-apply-helpers');
|
|
||||||
|
|
||||||
/** @type {(thisArg: string, searchString: string, position?: number) => number} */
|
|
||||||
var $indexOf = callBindBasic([GetIntrinsic('%String.prototype.indexOf%')]);
|
|
||||||
|
|
||||||
/** @type {import('.')} */
|
|
||||||
module.exports = function callBoundIntrinsic(name, allowMissing) {
|
|
||||||
/* eslint no-extra-parens: 0 */
|
|
||||||
|
|
||||||
var intrinsic = /** @type {(this: unknown, ...args: unknown[]) => unknown} */ (GetIntrinsic(name, !!allowMissing));
|
|
||||||
if (typeof intrinsic === 'function' && $indexOf(name, '.prototype.') > -1) {
|
|
||||||
return callBindBasic(/** @type {const} */ ([intrinsic]));
|
|
||||||
}
|
|
||||||
return intrinsic;
|
|
||||||
};
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
{
|
|
||||||
"name": "call-bound",
|
|
||||||
"version": "1.0.4",
|
|
||||||
"description": "Robust call-bound JavaScript intrinsics, using `call-bind` and `get-intrinsic`.",
|
|
||||||
"main": "index.js",
|
|
||||||
"exports": {
|
|
||||||
".": "./index.js",
|
|
||||||
"./package.json": "./package.json"
|
|
||||||
},
|
|
||||||
"sideEffects": false,
|
|
||||||
"scripts": {
|
|
||||||
"prepack": "npmignore --auto --commentLines=auto",
|
|
||||||
"prepublish": "not-in-publish || npm run prepublishOnly",
|
|
||||||
"prepublishOnly": "safe-publish-latest",
|
|
||||||
"prelint": "evalmd README.md",
|
|
||||||
"lint": "eslint --ext=.js,.mjs .",
|
|
||||||
"postlint": "tsc -p . && attw -P",
|
|
||||||
"pretest": "npm run lint",
|
|
||||||
"tests-only": "nyc tape 'test/**/*.js'",
|
|
||||||
"test": "npm run tests-only",
|
|
||||||
"posttest": "npx npm@'>=10.2' audit --production",
|
|
||||||
"version": "auto-changelog && git add CHANGELOG.md",
|
|
||||||
"postversion": "auto-changelog && git add CHANGELOG.md && git commit --no-edit --amend && git tag -f \"v$(node -e \"console.log(require('./package.json').version)\")\""
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "git+https://github.com/ljharb/call-bound.git"
|
|
||||||
},
|
|
||||||
"keywords": [
|
|
||||||
"javascript",
|
|
||||||
"ecmascript",
|
|
||||||
"es",
|
|
||||||
"js",
|
|
||||||
"callbind",
|
|
||||||
"callbound",
|
|
||||||
"call",
|
|
||||||
"bind",
|
|
||||||
"bound",
|
|
||||||
"call-bind",
|
|
||||||
"call-bound",
|
|
||||||
"function",
|
|
||||||
"es-abstract"
|
|
||||||
],
|
|
||||||
"author": "Jordan Harband <ljharb@gmail.com>",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
},
|
|
||||||
"license": "MIT",
|
|
||||||
"bugs": {
|
|
||||||
"url": "https://github.com/ljharb/call-bound/issues"
|
|
||||||
},
|
|
||||||
"homepage": "https://github.com/ljharb/call-bound#readme",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
|
||||||
"get-intrinsic": "^1.3.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@arethetypeswrong/cli": "^0.17.4",
|
|
||||||
"@ljharb/eslint-config": "^21.1.1",
|
|
||||||
"@ljharb/tsconfig": "^0.3.0",
|
|
||||||
"@types/call-bind": "^1.0.5",
|
|
||||||
"@types/get-intrinsic": "^1.2.3",
|
|
||||||
"@types/tape": "^5.8.1",
|
|
||||||
"auto-changelog": "^2.5.0",
|
|
||||||
"encoding": "^0.1.13",
|
|
||||||
"es-value-fixtures": "^1.7.1",
|
|
||||||
"eslint": "=8.8.0",
|
|
||||||
"evalmd": "^0.0.19",
|
|
||||||
"for-each": "^0.3.5",
|
|
||||||
"gopd": "^1.2.0",
|
|
||||||
"has-strict-mode": "^1.1.0",
|
|
||||||
"in-publish": "^2.0.1",
|
|
||||||
"npmignore": "^0.3.1",
|
|
||||||
"nyc": "^10.3.2",
|
|
||||||
"object-inspect": "^1.13.4",
|
|
||||||
"safe-publish-latest": "^2.0.0",
|
|
||||||
"tape": "^5.9.0",
|
|
||||||
"typescript": "next"
|
|
||||||
},
|
|
||||||
"testling": {
|
|
||||||
"files": "test/index.js"
|
|
||||||
},
|
|
||||||
"auto-changelog": {
|
|
||||||
"output": "CHANGELOG.md",
|
|
||||||
"template": "keepachangelog",
|
|
||||||
"unreleased": false,
|
|
||||||
"commitLimit": false,
|
|
||||||
"backfillLimit": false,
|
|
||||||
"hideCredit": true
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"ignore": [
|
|
||||||
".github/workflows"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
var test = require('tape');
|
|
||||||
|
|
||||||
var callBound = require('../');
|
|
||||||
|
|
||||||
/** @template {true} T @template U @typedef {T extends U ? T : never} AssertType */
|
|
||||||
|
|
||||||
test('callBound', function (t) {
|
|
||||||
// static primitive
|
|
||||||
t.equal(callBound('Array.length'), Array.length, 'Array.length yields itself');
|
|
||||||
t.equal(callBound('%Array.length%'), Array.length, '%Array.length% yields itself');
|
|
||||||
|
|
||||||
// static non-function object
|
|
||||||
t.equal(callBound('Array.prototype'), Array.prototype, 'Array.prototype yields itself');
|
|
||||||
t.equal(callBound('%Array.prototype%'), Array.prototype, '%Array.prototype% yields itself');
|
|
||||||
t.equal(callBound('Array.constructor'), Array.constructor, 'Array.constructor yields itself');
|
|
||||||
t.equal(callBound('%Array.constructor%'), Array.constructor, '%Array.constructor% yields itself');
|
|
||||||
|
|
||||||
// static function
|
|
||||||
t.equal(callBound('Date.parse'), Date.parse, 'Date.parse yields itself');
|
|
||||||
t.equal(callBound('%Date.parse%'), Date.parse, '%Date.parse% yields itself');
|
|
||||||
|
|
||||||
// prototype primitive
|
|
||||||
t.equal(callBound('Error.prototype.message'), Error.prototype.message, 'Error.prototype.message yields itself');
|
|
||||||
t.equal(callBound('%Error.prototype.message%'), Error.prototype.message, '%Error.prototype.message% yields itself');
|
|
||||||
|
|
||||||
var x = callBound('Object.prototype.toString');
|
|
||||||
var y = callBound('%Object.prototype.toString%');
|
|
||||||
|
|
||||||
// prototype function
|
|
||||||
t.notEqual(x, Object.prototype.toString, 'Object.prototype.toString does not yield itself');
|
|
||||||
t.notEqual(y, Object.prototype.toString, '%Object.prototype.toString% does not yield itself');
|
|
||||||
t.equal(x(true), Object.prototype.toString.call(true), 'call-bound Object.prototype.toString calls into the original');
|
|
||||||
t.equal(y(true), Object.prototype.toString.call(true), 'call-bound %Object.prototype.toString% calls into the original');
|
|
||||||
|
|
||||||
t['throws'](
|
|
||||||
// @ts-expect-error
|
|
||||||
function () { callBound('does not exist'); },
|
|
||||||
SyntaxError,
|
|
||||||
'nonexistent intrinsic throws'
|
|
||||||
);
|
|
||||||
t['throws'](
|
|
||||||
// @ts-expect-error
|
|
||||||
function () { callBound('does not exist', true); },
|
|
||||||
SyntaxError,
|
|
||||||
'allowMissing arg still throws for unknown intrinsic'
|
|
||||||
);
|
|
||||||
|
|
||||||
t.test('real but absent intrinsic', { skip: typeof WeakRef !== 'undefined' }, function (st) {
|
|
||||||
st['throws'](
|
|
||||||
function () { callBound('WeakRef'); },
|
|
||||||
TypeError,
|
|
||||||
'real but absent intrinsic throws'
|
|
||||||
);
|
|
||||||
st.equal(callBound('WeakRef', true), undefined, 'allowMissing arg avoids exception');
|
|
||||||
st.end();
|
|
||||||
});
|
|
||||||
|
|
||||||
t.end();
|
|
||||||
});
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
{
|
|
||||||
"extends": "@ljharb/tsconfig",
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ESNext",
|
|
||||||
"lib": ["es2024"],
|
|
||||||
},
|
|
||||||
"exclude": [
|
|
||||||
"coverage",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
(The MIT License)
|
|
||||||
|
|
||||||
Copyright (c) 2014-2017 Douglas Christopher Wilson
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of this software and associated documentation files (the
|
|
||||||
'Software'), to deal in the Software without restriction, including
|
|
||||||
without limitation the rights to use, copy, modify, merge, publish,
|
|
||||||
distribute, sublicense, and/or sell copies of the Software, and to
|
|
||||||
permit persons to whom the Software is furnished to do so, subject to
|
|
||||||
the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be
|
|
||||||
included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
||||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
||||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
||||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
||||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
# content-disposition
|
|
||||||
|
|
||||||
[![NPM Version][npm-image]][npm-url]
|
|
||||||
[![NPM Downloads][downloads-image]][downloads-url]
|
|
||||||
[![Node.js Version][node-version-image]][node-version-url]
|
|
||||||
[![Build Status][github-actions-ci-image]][github-actions-ci-url]
|
|
||||||
[![Test Coverage][coveralls-image]][coveralls-url]
|
|
||||||
|
|
||||||
Create and parse HTTP `Content-Disposition` header
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ npm install content-disposition
|
|
||||||
```
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
```js
|
|
||||||
const contentDisposition = require('content-disposition')
|
|
||||||
```
|
|
||||||
|
|
||||||
### contentDisposition(filename, options)
|
|
||||||
|
|
||||||
Create an attachment `Content-Disposition` header value using the given file name,
|
|
||||||
if supplied. The `filename` is optional and if no file name is desired, but you
|
|
||||||
want to specify `options`, set `filename` to `undefined`.
|
|
||||||
|
|
||||||
```js
|
|
||||||
res.setHeader('Content-Disposition', contentDisposition('∫ maths.pdf'))
|
|
||||||
```
|
|
||||||
|
|
||||||
**note** HTTP headers are of the ISO-8859-1 character set. If you are writing this
|
|
||||||
header through a means different from `setHeader` in Node.js, you'll want to specify
|
|
||||||
the `'binary'` encoding in Node.js.
|
|
||||||
|
|
||||||
#### Options
|
|
||||||
|
|
||||||
`contentDisposition` accepts these properties in the options object.
|
|
||||||
|
|
||||||
##### fallback
|
|
||||||
|
|
||||||
If the `filename` option is outside ISO-8859-1, then the file name is actually
|
|
||||||
stored in a supplemental field for clients that support Unicode file names and
|
|
||||||
a ISO-8859-1 version of the file name is automatically generated.
|
|
||||||
|
|
||||||
This specifies the ISO-8859-1 file name to override the automatic generation or
|
|
||||||
disables the generation all together, defaults to `true`.
|
|
||||||
|
|
||||||
- A string will specify the ISO-8859-1 file name to use in place of automatic
|
|
||||||
generation.
|
|
||||||
- `false` will disable including a ISO-8859-1 file name and only include the
|
|
||||||
Unicode version (unless the file name is already ISO-8859-1).
|
|
||||||
- `true` will enable automatic generation if the file name is outside ISO-8859-1.
|
|
||||||
|
|
||||||
If the `filename` option is ISO-8859-1 and this option is specified and has a
|
|
||||||
different value, then the `filename` option is encoded in the extended field
|
|
||||||
and this set as the fallback field, even though they are both ISO-8859-1.
|
|
||||||
|
|
||||||
##### type
|
|
||||||
|
|
||||||
Specifies the disposition type, defaults to `"attachment"`. This can also be
|
|
||||||
`"inline"`, or any other value (all values except inline are treated like
|
|
||||||
`attachment`, but can convey additional information if both parties agree to
|
|
||||||
it). The type is normalized to lower-case.
|
|
||||||
|
|
||||||
### contentDisposition.parse(string)
|
|
||||||
|
|
||||||
```js
|
|
||||||
const disposition = contentDisposition.parse('attachment; filename="EURO rates.txt"; filename*=UTF-8\'\'%e2%82%ac%20rates.txt')
|
|
||||||
```
|
|
||||||
|
|
||||||
Parse a `Content-Disposition` header string. This automatically handles extended
|
|
||||||
("Unicode") parameters by decoding them and providing them under the standard
|
|
||||||
parameter name. This will return an object with the following properties (examples
|
|
||||||
are shown for the string `'attachment; filename="EURO rates.txt"; filename*=UTF-8\'\'%e2%82%ac%20rates.txt'`):
|
|
||||||
|
|
||||||
- `type`: The disposition type (always lower case). Example: `'attachment'`
|
|
||||||
|
|
||||||
- `parameters`: An object of the parameters in the disposition (name of parameter
|
|
||||||
always lower case and extended versions replace non-extended versions). Example:
|
|
||||||
`{filename: "€ rates.txt"}`
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Send a file for download
|
|
||||||
|
|
||||||
```js
|
|
||||||
const contentDisposition = require('content-disposition')
|
|
||||||
const fs = require('fs')
|
|
||||||
const http = require('http')
|
|
||||||
const onFinished = require('on-finished')
|
|
||||||
|
|
||||||
const filePath = '/path/to/public/plans.pdf'
|
|
||||||
|
|
||||||
http.createServer(function onRequest (req, res) {
|
|
||||||
// set headers
|
|
||||||
res.setHeader('Content-Type', 'application/pdf')
|
|
||||||
res.setHeader('Content-Disposition', contentDisposition(filePath))
|
|
||||||
|
|
||||||
// send file
|
|
||||||
const stream = fs.createReadStream(filePath)
|
|
||||||
stream.pipe(res)
|
|
||||||
onFinished(res, function () {
|
|
||||||
stream.destroy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ npm test
|
|
||||||
```
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [RFC 2616: Hypertext Transfer Protocol -- HTTP/1.1][rfc-2616]
|
|
||||||
- [RFC 5987: Character Set and Language Encoding for Hypertext Transfer Protocol (HTTP) Header Field Parameters][rfc-5987]
|
|
||||||
- [RFC 6266: Use of the Content-Disposition Header Field in the Hypertext Transfer Protocol (HTTP)][rfc-6266]
|
|
||||||
- [Test Cases for HTTP Content-Disposition header field (RFC 6266) and the Encodings defined in RFCs 2047, 2231 and 5987][tc-2231]
|
|
||||||
|
|
||||||
[rfc-2616]: https://datatracker.ietf.org/doc/html/rfc2616
|
|
||||||
[rfc-5987]: https://datatracker.ietf.org/doc/html/rfc5987
|
|
||||||
[rfc-6266]: https://datatracker.ietf.org/doc/html/rfc6266
|
|
||||||
[tc-2231]: http://greenbytes.de/tech/tc2231/
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[MIT](LICENSE)
|
|
||||||
|
|
||||||
[npm-image]: https://img.shields.io/npm/v/content-disposition
|
|
||||||
[npm-url]: https://www.npmjs.com/package/content-disposition
|
|
||||||
[node-version-image]: https://img.shields.io/node/v/content-disposition
|
|
||||||
[node-version-url]: https://nodejs.org/en/download
|
|
||||||
[coveralls-image]: https://img.shields.io/coverallsCoverage/github/jshttp/content-disposition
|
|
||||||
[coveralls-url]: https://coveralls.io/github/jshttp/content-disposition?branch=master
|
|
||||||
[downloads-image]: https://img.shields.io/npm/dm/content-disposition
|
|
||||||
[downloads-url]: https://www.npmjs.com/package/content-disposition
|
|
||||||
[github-actions-ci-image]: https://img.shields.io/github/actions/workflow/status/jshttp/content-disposition/ci.yml
|
|
||||||
[github-actions-ci-url]: https://github.com/jshttp/content-disposition/actions/workflows/ci.yml
|
|
||||||
|
|
@ -1,536 +0,0 @@
|
||||||
/*!
|
|
||||||
* content-disposition
|
|
||||||
* Copyright(c) 2014-2017 Douglas Christopher Wilson
|
|
||||||
* MIT Licensed
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module exports.
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
module.exports = contentDisposition
|
|
||||||
module.exports.parse = parse
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TextDecoder instance for UTF-8 decoding when decodeURIComponent fails due to invalid byte sequences.
|
|
||||||
* @type {TextDecoder}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
const utf8Decoder = new TextDecoder('utf-8')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RegExp to match non attr-char, *after* encodeURIComponent (i.e. not including "%")
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
var ENCODE_URL_ATTR_CHAR_REGEXP = /[\x00-\x20"'()*,/:;<=>?@[\\\]{}\x7f]/g // eslint-disable-line no-control-regex
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RegExp to match non-latin1 characters.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
var NON_LATIN1_REGEXP = /[^\x20-\x7e\xa0-\xff]/g
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RegExp to match quoted-pair in RFC 2616
|
|
||||||
*
|
|
||||||
* quoted-pair = "\" CHAR
|
|
||||||
* CHAR = <any US-ASCII character (octets 0 - 127)>
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
var QESC_REGEXP = /\\([\u0000-\u007f])/g // eslint-disable-line no-control-regex
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RegExp to match chars that must be quoted-pair in RFC 2616
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
var QUOTE_REGEXP = /([\\"])/g
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RegExp for various RFC 2616 grammar
|
|
||||||
*
|
|
||||||
* parameter = token "=" ( token | quoted-string )
|
|
||||||
* token = 1*<any CHAR except CTLs or separators>
|
|
||||||
* separators = "(" | ")" | "<" | ">" | "@"
|
|
||||||
* | "," | ";" | ":" | "\" | <">
|
|
||||||
* | "/" | "[" | "]" | "?" | "="
|
|
||||||
* | "{" | "}" | SP | HT
|
|
||||||
* quoted-string = ( <"> *(qdtext | quoted-pair ) <"> )
|
|
||||||
* qdtext = <any TEXT except <">>
|
|
||||||
* quoted-pair = "\" CHAR
|
|
||||||
* CHAR = <any US-ASCII character (octets 0 - 127)>
|
|
||||||
* TEXT = <any OCTET except CTLs, but including LWS>
|
|
||||||
* LWS = [CRLF] 1*( SP | HT )
|
|
||||||
* CRLF = CR LF
|
|
||||||
* CR = <US-ASCII CR, carriage return (13)>
|
|
||||||
* LF = <US-ASCII LF, linefeed (10)>
|
|
||||||
* SP = <US-ASCII SP, space (32)>
|
|
||||||
* HT = <US-ASCII HT, horizontal-tab (9)>
|
|
||||||
* CTL = <any US-ASCII control character (octets 0 - 31) and DEL (127)>
|
|
||||||
* OCTET = <any 8-bit sequence of data>
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
var PARAM_REGEXP = /;[\x09\x20]*([!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*=[\x09\x20]*("(?:[\x20!\x23-\x5b\x5d-\x7e\x80-\xff]|\\[\x20-\x7e])*"|[!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*/g // eslint-disable-line no-control-regex
|
|
||||||
var TEXT_REGEXP = /^[\x20-\x7e\x80-\xff]+$/
|
|
||||||
var TOKEN_REGEXP = /^[!#$%&'*+.0-9A-Z^_`a-z|~-]+$/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RegExp for various RFC 5987 grammar
|
|
||||||
*
|
|
||||||
* ext-value = charset "'" [ language ] "'" value-chars
|
|
||||||
* charset = "UTF-8" / "ISO-8859-1" / mime-charset
|
|
||||||
* mime-charset = 1*mime-charsetc
|
|
||||||
* mime-charsetc = ALPHA / DIGIT
|
|
||||||
* / "!" / "#" / "$" / "%" / "&"
|
|
||||||
* / "+" / "-" / "^" / "_" / "`"
|
|
||||||
* / "{" / "}" / "~"
|
|
||||||
* language = ( 2*3ALPHA [ extlang ] )
|
|
||||||
* / 4ALPHA
|
|
||||||
* / 5*8ALPHA
|
|
||||||
* extlang = *3( "-" 3ALPHA )
|
|
||||||
* value-chars = *( pct-encoded / attr-char )
|
|
||||||
* pct-encoded = "%" HEXDIG HEXDIG
|
|
||||||
* attr-char = ALPHA / DIGIT
|
|
||||||
* / "!" / "#" / "$" / "&" / "+" / "-" / "."
|
|
||||||
* / "^" / "_" / "`" / "|" / "~"
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
var EXT_VALUE_REGEXP = /^([A-Za-z0-9!#$%&+\-^_`{}~]+)'(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4,8}|)'((?:%[0-9A-Fa-f]{2}|[A-Za-z0-9!#$&+.^_`|~-])+)$/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RegExp for various RFC 6266 grammar
|
|
||||||
*
|
|
||||||
* disposition-type = "inline" | "attachment" | disp-ext-type
|
|
||||||
* disp-ext-type = token
|
|
||||||
* disposition-parm = filename-parm | disp-ext-parm
|
|
||||||
* filename-parm = "filename" "=" value
|
|
||||||
* | "filename*" "=" ext-value
|
|
||||||
* disp-ext-parm = token "=" value
|
|
||||||
* | ext-token "=" ext-value
|
|
||||||
* ext-token = <the characters in token, followed by "*">
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
var DISPOSITION_TYPE_REGEXP = /^([!#$%&'*+.0-9A-Z^_`a-z|~-]+)[\x09\x20]*(?:$|;)/ // eslint-disable-line no-control-regex
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an attachment Content-Disposition header.
|
|
||||||
*
|
|
||||||
* @param {string} [filename]
|
|
||||||
* @param {object} [options]
|
|
||||||
* @param {string} [options.type=attachment]
|
|
||||||
* @param {string|boolean} [options.fallback=true]
|
|
||||||
* @return {string}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
function contentDisposition (filename, options) {
|
|
||||||
var opts = options || {}
|
|
||||||
|
|
||||||
// get type
|
|
||||||
var type = opts.type || 'attachment'
|
|
||||||
|
|
||||||
// get parameters
|
|
||||||
var params = createparams(filename, opts.fallback)
|
|
||||||
|
|
||||||
// format into string
|
|
||||||
return format(new ContentDisposition(type, params))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create parameters object from filename and fallback.
|
|
||||||
*
|
|
||||||
* @param {string} [filename]
|
|
||||||
* @param {string|boolean} [fallback=true]
|
|
||||||
* @return {object}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
function createparams (filename, fallback) {
|
|
||||||
if (filename === undefined) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var params = {}
|
|
||||||
|
|
||||||
if (typeof filename !== 'string') {
|
|
||||||
throw new TypeError('filename must be a string')
|
|
||||||
}
|
|
||||||
|
|
||||||
// fallback defaults to true
|
|
||||||
if (fallback === undefined) {
|
|
||||||
fallback = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof fallback !== 'string' && typeof fallback !== 'boolean') {
|
|
||||||
throw new TypeError('fallback must be a string or boolean')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof fallback === 'string' && NON_LATIN1_REGEXP.test(fallback)) {
|
|
||||||
throw new TypeError('fallback must be ISO-8859-1 string')
|
|
||||||
}
|
|
||||||
|
|
||||||
// restrict to file base name
|
|
||||||
var name = basename(filename)
|
|
||||||
|
|
||||||
// determine if name is suitable for quoted string
|
|
||||||
var isQuotedString = TEXT_REGEXP.test(name)
|
|
||||||
|
|
||||||
// generate fallback name
|
|
||||||
var fallbackName = typeof fallback !== 'string'
|
|
||||||
? fallback && getlatin1(name)
|
|
||||||
: basename(fallback)
|
|
||||||
var hasFallback = typeof fallbackName === 'string' && fallbackName !== name
|
|
||||||
|
|
||||||
// set extended filename parameter
|
|
||||||
if (hasFallback || !isQuotedString || hasHexEscape(name)) {
|
|
||||||
params['filename*'] = name
|
|
||||||
}
|
|
||||||
|
|
||||||
// set filename parameter
|
|
||||||
if (isQuotedString || hasFallback) {
|
|
||||||
params.filename = hasFallback
|
|
||||||
? fallbackName
|
|
||||||
: name
|
|
||||||
}
|
|
||||||
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format object to Content-Disposition header.
|
|
||||||
*
|
|
||||||
* @param {object} obj
|
|
||||||
* @param {string} obj.type
|
|
||||||
* @param {object} [obj.parameters]
|
|
||||||
* @return {string}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
function format (obj) {
|
|
||||||
var parameters = obj.parameters
|
|
||||||
var type = obj.type
|
|
||||||
|
|
||||||
if (!type || typeof type !== 'string' || !TOKEN_REGEXP.test(type)) {
|
|
||||||
throw new TypeError('invalid type')
|
|
||||||
}
|
|
||||||
|
|
||||||
// start with normalized type
|
|
||||||
var string = String(type).toLowerCase()
|
|
||||||
|
|
||||||
// append parameters
|
|
||||||
if (parameters && typeof parameters === 'object') {
|
|
||||||
var param
|
|
||||||
var params = Object.keys(parameters).sort()
|
|
||||||
|
|
||||||
for (var i = 0; i < params.length; i++) {
|
|
||||||
param = params[i]
|
|
||||||
|
|
||||||
var val = param.slice(-1) === '*'
|
|
||||||
? ustring(parameters[param])
|
|
||||||
: qstring(parameters[param])
|
|
||||||
|
|
||||||
string += '; ' + param + '=' + val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode a RFC 5987 field value (gracefully).
|
|
||||||
*
|
|
||||||
* @param {string} str
|
|
||||||
* @return {string}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
function decodefield (str) {
|
|
||||||
const match = EXT_VALUE_REGEXP.exec(str)
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
throw new TypeError('invalid extended field value')
|
|
||||||
}
|
|
||||||
|
|
||||||
const charset = match[1].toLowerCase()
|
|
||||||
const encoded = match[2]
|
|
||||||
|
|
||||||
switch (charset) {
|
|
||||||
case 'iso-8859-1':
|
|
||||||
{
|
|
||||||
const binary = decodeHexEscapes(encoded)
|
|
||||||
return getlatin1(binary)
|
|
||||||
}
|
|
||||||
case 'utf-8':
|
|
||||||
case 'utf8':
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(encoded)
|
|
||||||
} catch {
|
|
||||||
// Failed to decode with decodeURIComponent, fallback to lenient decoding which replaces invalid UTF-8 byte sequences with the Unicode replacement character
|
|
||||||
// TODO: Consider removing in the next major version to be more strict about invalid percent-encodings
|
|
||||||
const binary = decodeHexEscapes(encoded)
|
|
||||||
|
|
||||||
const bytes = new Uint8Array(binary.length)
|
|
||||||
for (let idx = 0; idx < binary.length; idx++) {
|
|
||||||
bytes[idx] = binary.charCodeAt(idx)
|
|
||||||
}
|
|
||||||
|
|
||||||
return utf8Decoder.decode(bytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new TypeError('unsupported charset in extended field')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get ISO-8859-1 version of string.
|
|
||||||
*
|
|
||||||
* @param {string} val
|
|
||||||
* @return {string}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
function getlatin1 (val) {
|
|
||||||
// simple Unicode -> ISO-8859-1 transformation
|
|
||||||
return String(val).replace(NON_LATIN1_REGEXP, '?')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse Content-Disposition header string.
|
|
||||||
*
|
|
||||||
* @param {string} string
|
|
||||||
* @return {object}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
function parse (string) {
|
|
||||||
if (!string || typeof string !== 'string') {
|
|
||||||
throw new TypeError('argument string is required')
|
|
||||||
}
|
|
||||||
|
|
||||||
var match = DISPOSITION_TYPE_REGEXP.exec(string)
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
throw new TypeError('invalid type format')
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalize type
|
|
||||||
var index = match[0].length
|
|
||||||
var type = match[1].toLowerCase()
|
|
||||||
|
|
||||||
var key
|
|
||||||
var names = []
|
|
||||||
var params = {}
|
|
||||||
var value
|
|
||||||
|
|
||||||
// calculate index to start at
|
|
||||||
index = PARAM_REGEXP.lastIndex = match[0].slice(-1) === ';'
|
|
||||||
? index - 1
|
|
||||||
: index
|
|
||||||
|
|
||||||
// match parameters
|
|
||||||
while ((match = PARAM_REGEXP.exec(string))) {
|
|
||||||
if (match.index !== index) {
|
|
||||||
throw new TypeError('invalid parameter format')
|
|
||||||
}
|
|
||||||
|
|
||||||
index += match[0].length
|
|
||||||
key = match[1].toLowerCase()
|
|
||||||
value = match[2]
|
|
||||||
|
|
||||||
if (names.indexOf(key) !== -1) {
|
|
||||||
throw new TypeError('invalid duplicate parameter')
|
|
||||||
}
|
|
||||||
|
|
||||||
names.push(key)
|
|
||||||
|
|
||||||
if (key.indexOf('*') + 1 === key.length) {
|
|
||||||
// decode extended value
|
|
||||||
key = key.slice(0, -1)
|
|
||||||
value = decodefield(value)
|
|
||||||
|
|
||||||
// overwrite existing value
|
|
||||||
params[key] = value
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof params[key] === 'string') {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value[0] === '"') {
|
|
||||||
// remove quotes and escapes
|
|
||||||
value = value
|
|
||||||
.slice(1, -1)
|
|
||||||
.replace(QESC_REGEXP, '$1')
|
|
||||||
}
|
|
||||||
|
|
||||||
params[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
if (index !== -1 && index !== string.length) {
|
|
||||||
throw new TypeError('invalid parameter format')
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ContentDisposition(type, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Percent encode a single character.
|
|
||||||
*
|
|
||||||
* @param {string} char
|
|
||||||
* @return {string}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
function pencode (char) {
|
|
||||||
return '%' + String(char)
|
|
||||||
.charCodeAt(0)
|
|
||||||
.toString(16)
|
|
||||||
.toUpperCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Quote a string for HTTP.
|
|
||||||
*
|
|
||||||
* @param {string} val
|
|
||||||
* @return {string}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
function qstring (val) {
|
|
||||||
var str = String(val)
|
|
||||||
|
|
||||||
return '"' + str.replace(QUOTE_REGEXP, '\\$1') + '"'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode a Unicode string for HTTP (RFC 5987).
|
|
||||||
*
|
|
||||||
* @param {string} val
|
|
||||||
* @return {string}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
function ustring (val) {
|
|
||||||
var str = String(val)
|
|
||||||
|
|
||||||
// percent encode as UTF-8
|
|
||||||
var encoded = encodeURIComponent(str)
|
|
||||||
.replace(ENCODE_URL_ATTR_CHAR_REGEXP, pencode)
|
|
||||||
|
|
||||||
return 'UTF-8\'\'' + encoded
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class for parsed Content-Disposition header for v8 optimization
|
|
||||||
*
|
|
||||||
* @public
|
|
||||||
* @param {string} type
|
|
||||||
* @param {object} parameters
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
|
|
||||||
function ContentDisposition (type, parameters) {
|
|
||||||
this.type = type
|
|
||||||
this.parameters = parameters
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the last portion of a path
|
|
||||||
*
|
|
||||||
* @param {string} path
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
function basename (path) {
|
|
||||||
const normalized = path.replaceAll('\\', '/')
|
|
||||||
|
|
||||||
let end = normalized.length
|
|
||||||
while (end > 0 && normalized[end - 1] === '/') {
|
|
||||||
end--
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end === 0) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
let start = end - 1
|
|
||||||
while (start >= 0 && normalized[start] !== '/') {
|
|
||||||
start--
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized.slice(start + 1, end)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a character is a hex digit [0-9A-Fa-f]
|
|
||||||
*
|
|
||||||
* @param {string} char
|
|
||||||
* @return {boolean}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function isHexDigit (char) {
|
|
||||||
const code = char.charCodeAt(0)
|
|
||||||
return (
|
|
||||||
(code >= 48 && code <= 57) || // 0-9
|
|
||||||
(code >= 65 && code <= 70) || // A-F
|
|
||||||
(code >= 97 && code <= 102) // a-f
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a string contains percent encoding escapes.
|
|
||||||
*
|
|
||||||
* @param {string} str
|
|
||||||
* @return {boolean}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function hasHexEscape (str) {
|
|
||||||
const maxIndex = str.length - 3
|
|
||||||
let lastIndex = -1
|
|
||||||
|
|
||||||
while ((lastIndex = str.indexOf('%', lastIndex + 1)) !== -1 && lastIndex <= maxIndex) {
|
|
||||||
if (isHexDigit(str[lastIndex + 1]) && isHexDigit(str[lastIndex + 2])) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode hex escapes in a string (e.g., %20 -> space)
|
|
||||||
*
|
|
||||||
* @param {string} str
|
|
||||||
* @return {string}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function decodeHexEscapes (str) {
|
|
||||||
const firstEscape = str.indexOf('%')
|
|
||||||
if (firstEscape === -1) return str
|
|
||||||
|
|
||||||
let result = str.slice(0, firstEscape)
|
|
||||||
for (let idx = firstEscape; idx < str.length; idx++) {
|
|
||||||
if (
|
|
||||||
str[idx] === '%' &&
|
|
||||||
idx + 2 < str.length &&
|
|
||||||
isHexDigit(str[idx + 1]) &&
|
|
||||||
isHexDigit(str[idx + 2])
|
|
||||||
) {
|
|
||||||
result += String.fromCharCode(Number.parseInt(str[idx + 1] + str[idx + 2], 16))
|
|
||||||
idx += 2
|
|
||||||
} else {
|
|
||||||
result += str[idx]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
{
|
|
||||||
"name": "content-disposition",
|
|
||||||
"description": "Create and parse Content-Disposition header",
|
|
||||||
"version": "1.1.0",
|
|
||||||
"author": "Douglas Christopher Wilson <doug@somethingdoug.com>",
|
|
||||||
"license": "MIT",
|
|
||||||
"keywords": [
|
|
||||||
"content-disposition",
|
|
||||||
"http",
|
|
||||||
"rfc6266",
|
|
||||||
"res"
|
|
||||||
],
|
|
||||||
"repository": "jshttp/content-disposition",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"c8": "^10.1.2",
|
|
||||||
"eslint": "^8.57.1",
|
|
||||||
"eslint-config-standard": "^14.1.1",
|
|
||||||
"eslint-plugin-import": "^2.32.0",
|
|
||||||
"eslint-plugin-markdown": "^3.0.1",
|
|
||||||
"eslint-plugin-node": "^11.1.0",
|
|
||||||
"eslint-plugin-promise": "^6.6.0",
|
|
||||||
"eslint-plugin-standard": "^4.1.0"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"index.js"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"lint": "eslint .",
|
|
||||||
"test": "node --test --test-reporter spec",
|
|
||||||
"test-ci": "c8 --reporter=lcovonly --reporter=text npm test",
|
|
||||||
"test-cov": "c8 --reporter=html --reporter=text npm test"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
1.0.5 / 2023-01-29
|
|
||||||
==================
|
|
||||||
|
|
||||||
* perf: skip value escaping when unnecessary
|
|
||||||
|
|
||||||
1.0.4 / 2017-09-11
|
|
||||||
==================
|
|
||||||
|
|
||||||
* perf: skip parameter parsing when no parameters
|
|
||||||
|
|
||||||
1.0.3 / 2017-09-10
|
|
||||||
==================
|
|
||||||
|
|
||||||
* perf: remove argument reassignment
|
|
||||||
|
|
||||||
1.0.2 / 2016-05-09
|
|
||||||
==================
|
|
||||||
|
|
||||||
* perf: enable strict mode
|
|
||||||
|
|
||||||
1.0.1 / 2015-02-13
|
|
||||||
==================
|
|
||||||
|
|
||||||
* Improve missing `Content-Type` header error message
|
|
||||||
|
|
||||||
1.0.0 / 2015-02-01
|
|
||||||
==================
|
|
||||||
|
|
||||||
* Initial implementation, derived from `media-typer@0.3.0`
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
(The MIT License)
|
|
||||||
|
|
||||||
Copyright (c) 2015 Douglas Christopher Wilson
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of this software and associated documentation files (the
|
|
||||||
'Software'), to deal in the Software without restriction, including
|
|
||||||
without limitation the rights to use, copy, modify, merge, publish,
|
|
||||||
distribute, sublicense, and/or sell copies of the Software, and to
|
|
||||||
permit persons to whom the Software is furnished to do so, subject to
|
|
||||||
the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be
|
|
||||||
included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
||||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
||||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
||||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
||||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
# content-type
|
|
||||||
|
|
||||||
[![NPM Version][npm-version-image]][npm-url]
|
|
||||||
[![NPM Downloads][npm-downloads-image]][npm-url]
|
|
||||||
[![Node.js Version][node-image]][node-url]
|
|
||||||
[![Build Status][ci-image]][ci-url]
|
|
||||||
[![Coverage Status][coveralls-image]][coveralls-url]
|
|
||||||
|
|
||||||
Create and parse HTTP Content-Type header according to RFC 7231
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ npm install content-type
|
|
||||||
```
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
```js
|
|
||||||
var contentType = require('content-type')
|
|
||||||
```
|
|
||||||
|
|
||||||
### contentType.parse(string)
|
|
||||||
|
|
||||||
```js
|
|
||||||
var obj = contentType.parse('image/svg+xml; charset=utf-8')
|
|
||||||
```
|
|
||||||
|
|
||||||
Parse a `Content-Type` header. This will return an object with the following
|
|
||||||
properties (examples are shown for the string `'image/svg+xml; charset=utf-8'`):
|
|
||||||
|
|
||||||
- `type`: The media type (the type and subtype, always lower case).
|
|
||||||
Example: `'image/svg+xml'`
|
|
||||||
|
|
||||||
- `parameters`: An object of the parameters in the media type (name of parameter
|
|
||||||
always lower case). Example: `{charset: 'utf-8'}`
|
|
||||||
|
|
||||||
Throws a `TypeError` if the string is missing or invalid.
|
|
||||||
|
|
||||||
### contentType.parse(req)
|
|
||||||
|
|
||||||
```js
|
|
||||||
var obj = contentType.parse(req)
|
|
||||||
```
|
|
||||||
|
|
||||||
Parse the `Content-Type` header from the given `req`. Short-cut for
|
|
||||||
`contentType.parse(req.headers['content-type'])`.
|
|
||||||
|
|
||||||
Throws a `TypeError` if the `Content-Type` header is missing or invalid.
|
|
||||||
|
|
||||||
### contentType.parse(res)
|
|
||||||
|
|
||||||
```js
|
|
||||||
var obj = contentType.parse(res)
|
|
||||||
```
|
|
||||||
|
|
||||||
Parse the `Content-Type` header set on the given `res`. Short-cut for
|
|
||||||
`contentType.parse(res.getHeader('content-type'))`.
|
|
||||||
|
|
||||||
Throws a `TypeError` if the `Content-Type` header is missing or invalid.
|
|
||||||
|
|
||||||
### contentType.format(obj)
|
|
||||||
|
|
||||||
```js
|
|
||||||
var str = contentType.format({
|
|
||||||
type: 'image/svg+xml',
|
|
||||||
parameters: { charset: 'utf-8' }
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
Format an object into a `Content-Type` header. This will return a string of the
|
|
||||||
content type for the given object with the following properties (examples are
|
|
||||||
shown that produce the string `'image/svg+xml; charset=utf-8'`):
|
|
||||||
|
|
||||||
- `type`: The media type (will be lower-cased). Example: `'image/svg+xml'`
|
|
||||||
|
|
||||||
- `parameters`: An object of the parameters in the media type (name of the
|
|
||||||
parameter will be lower-cased). Example: `{charset: 'utf-8'}`
|
|
||||||
|
|
||||||
Throws a `TypeError` if the object contains an invalid type or parameter names.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[MIT](LICENSE)
|
|
||||||
|
|
||||||
[ci-image]: https://badgen.net/github/checks/jshttp/content-type/master?label=ci
|
|
||||||
[ci-url]: https://github.com/jshttp/content-type/actions/workflows/ci.yml
|
|
||||||
[coveralls-image]: https://badgen.net/coveralls/c/github/jshttp/content-type/master
|
|
||||||
[coveralls-url]: https://coveralls.io/r/jshttp/content-type?branch=master
|
|
||||||
[node-image]: https://badgen.net/npm/node/content-type
|
|
||||||
[node-url]: https://nodejs.org/en/download
|
|
||||||
[npm-downloads-image]: https://badgen.net/npm/dm/content-type
|
|
||||||
[npm-url]: https://npmjs.org/package/content-type
|
|
||||||
[npm-version-image]: https://badgen.net/npm/v/content-type
|
|
||||||
|
|
@ -1,225 +0,0 @@
|
||||||
/*!
|
|
||||||
* content-type
|
|
||||||
* Copyright(c) 2015 Douglas Christopher Wilson
|
|
||||||
* MIT Licensed
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RegExp to match *( ";" parameter ) in RFC 7231 sec 3.1.1.1
|
|
||||||
*
|
|
||||||
* parameter = token "=" ( token / quoted-string )
|
|
||||||
* token = 1*tchar
|
|
||||||
* tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*"
|
|
||||||
* / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
|
|
||||||
* / DIGIT / ALPHA
|
|
||||||
* ; any VCHAR, except delimiters
|
|
||||||
* quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE
|
|
||||||
* qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text
|
|
||||||
* obs-text = %x80-FF
|
|
||||||
* quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )
|
|
||||||
*/
|
|
||||||
var PARAM_REGEXP = /; *([!#$%&'*+.^_`|~0-9A-Za-z-]+) *= *("(?:[\u000b\u0020\u0021\u0023-\u005b\u005d-\u007e\u0080-\u00ff]|\\[\u000b\u0020-\u00ff])*"|[!#$%&'*+.^_`|~0-9A-Za-z-]+) */g // eslint-disable-line no-control-regex
|
|
||||||
var TEXT_REGEXP = /^[\u000b\u0020-\u007e\u0080-\u00ff]+$/ // eslint-disable-line no-control-regex
|
|
||||||
var TOKEN_REGEXP = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RegExp to match quoted-pair in RFC 7230 sec 3.2.6
|
|
||||||
*
|
|
||||||
* quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )
|
|
||||||
* obs-text = %x80-FF
|
|
||||||
*/
|
|
||||||
var QESC_REGEXP = /\\([\u000b\u0020-\u00ff])/g // eslint-disable-line no-control-regex
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RegExp to match chars that must be quoted-pair in RFC 7230 sec 3.2.6
|
|
||||||
*/
|
|
||||||
var QUOTE_REGEXP = /([\\"])/g
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RegExp to match type in RFC 7231 sec 3.1.1.1
|
|
||||||
*
|
|
||||||
* media-type = type "/" subtype
|
|
||||||
* type = token
|
|
||||||
* subtype = token
|
|
||||||
*/
|
|
||||||
var TYPE_REGEXP = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+\/[!#$%&'*+.^_`|~0-9A-Za-z-]+$/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module exports.
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
exports.format = format
|
|
||||||
exports.parse = parse
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format object to media type.
|
|
||||||
*
|
|
||||||
* @param {object} obj
|
|
||||||
* @return {string}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
function format (obj) {
|
|
||||||
if (!obj || typeof obj !== 'object') {
|
|
||||||
throw new TypeError('argument obj is required')
|
|
||||||
}
|
|
||||||
|
|
||||||
var parameters = obj.parameters
|
|
||||||
var type = obj.type
|
|
||||||
|
|
||||||
if (!type || !TYPE_REGEXP.test(type)) {
|
|
||||||
throw new TypeError('invalid type')
|
|
||||||
}
|
|
||||||
|
|
||||||
var string = type
|
|
||||||
|
|
||||||
// append parameters
|
|
||||||
if (parameters && typeof parameters === 'object') {
|
|
||||||
var param
|
|
||||||
var params = Object.keys(parameters).sort()
|
|
||||||
|
|
||||||
for (var i = 0; i < params.length; i++) {
|
|
||||||
param = params[i]
|
|
||||||
|
|
||||||
if (!TOKEN_REGEXP.test(param)) {
|
|
||||||
throw new TypeError('invalid parameter name')
|
|
||||||
}
|
|
||||||
|
|
||||||
string += '; ' + param + '=' + qstring(parameters[param])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse media type to object.
|
|
||||||
*
|
|
||||||
* @param {string|object} string
|
|
||||||
* @return {Object}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
function parse (string) {
|
|
||||||
if (!string) {
|
|
||||||
throw new TypeError('argument string is required')
|
|
||||||
}
|
|
||||||
|
|
||||||
// support req/res-like objects as argument
|
|
||||||
var header = typeof string === 'object'
|
|
||||||
? getcontenttype(string)
|
|
||||||
: string
|
|
||||||
|
|
||||||
if (typeof header !== 'string') {
|
|
||||||
throw new TypeError('argument string is required to be a string')
|
|
||||||
}
|
|
||||||
|
|
||||||
var index = header.indexOf(';')
|
|
||||||
var type = index !== -1
|
|
||||||
? header.slice(0, index).trim()
|
|
||||||
: header.trim()
|
|
||||||
|
|
||||||
if (!TYPE_REGEXP.test(type)) {
|
|
||||||
throw new TypeError('invalid media type')
|
|
||||||
}
|
|
||||||
|
|
||||||
var obj = new ContentType(type.toLowerCase())
|
|
||||||
|
|
||||||
// parse parameters
|
|
||||||
if (index !== -1) {
|
|
||||||
var key
|
|
||||||
var match
|
|
||||||
var value
|
|
||||||
|
|
||||||
PARAM_REGEXP.lastIndex = index
|
|
||||||
|
|
||||||
while ((match = PARAM_REGEXP.exec(header))) {
|
|
||||||
if (match.index !== index) {
|
|
||||||
throw new TypeError('invalid parameter format')
|
|
||||||
}
|
|
||||||
|
|
||||||
index += match[0].length
|
|
||||||
key = match[1].toLowerCase()
|
|
||||||
value = match[2]
|
|
||||||
|
|
||||||
if (value.charCodeAt(0) === 0x22 /* " */) {
|
|
||||||
// remove quotes
|
|
||||||
value = value.slice(1, -1)
|
|
||||||
|
|
||||||
// remove escapes
|
|
||||||
if (value.indexOf('\\') !== -1) {
|
|
||||||
value = value.replace(QESC_REGEXP, '$1')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
obj.parameters[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
if (index !== header.length) {
|
|
||||||
throw new TypeError('invalid parameter format')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return obj
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content-type from req/res objects.
|
|
||||||
*
|
|
||||||
* @param {object}
|
|
||||||
* @return {Object}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
function getcontenttype (obj) {
|
|
||||||
var header
|
|
||||||
|
|
||||||
if (typeof obj.getHeader === 'function') {
|
|
||||||
// res-like
|
|
||||||
header = obj.getHeader('content-type')
|
|
||||||
} else if (typeof obj.headers === 'object') {
|
|
||||||
// req-like
|
|
||||||
header = obj.headers && obj.headers['content-type']
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof header !== 'string') {
|
|
||||||
throw new TypeError('content-type header is missing from object')
|
|
||||||
}
|
|
||||||
|
|
||||||
return header
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Quote a string if necessary.
|
|
||||||
*
|
|
||||||
* @param {string} val
|
|
||||||
* @return {string}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
function qstring (val) {
|
|
||||||
var str = String(val)
|
|
||||||
|
|
||||||
// no need to quote tokens
|
|
||||||
if (TOKEN_REGEXP.test(str)) {
|
|
||||||
return str
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str.length > 0 && !TEXT_REGEXP.test(str)) {
|
|
||||||
throw new TypeError('invalid parameter value')
|
|
||||||
}
|
|
||||||
|
|
||||||
return '"' + str.replace(QUOTE_REGEXP, '\\$1') + '"'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class to represent a content type.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function ContentType (type) {
|
|
||||||
this.parameters = Object.create(null)
|
|
||||||
this.type = type
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
{
|
|
||||||
"name": "content-type",
|
|
||||||
"description": "Create and parse HTTP Content-Type header",
|
|
||||||
"version": "1.0.5",
|
|
||||||
"author": "Douglas Christopher Wilson <doug@somethingdoug.com>",
|
|
||||||
"license": "MIT",
|
|
||||||
"keywords": [
|
|
||||||
"content-type",
|
|
||||||
"http",
|
|
||||||
"req",
|
|
||||||
"res",
|
|
||||||
"rfc7231"
|
|
||||||
],
|
|
||||||
"repository": "jshttp/content-type",
|
|
||||||
"devDependencies": {
|
|
||||||
"deep-equal": "1.0.1",
|
|
||||||
"eslint": "8.32.0",
|
|
||||||
"eslint-config-standard": "15.0.1",
|
|
||||||
"eslint-plugin-import": "2.27.5",
|
|
||||||
"eslint-plugin-node": "11.1.0",
|
|
||||||
"eslint-plugin-promise": "6.1.1",
|
|
||||||
"eslint-plugin-standard": "4.1.0",
|
|
||||||
"mocha": "10.2.0",
|
|
||||||
"nyc": "15.1.0"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"LICENSE",
|
|
||||||
"HISTORY.md",
|
|
||||||
"README.md",
|
|
||||||
"index.js"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"lint": "eslint .",
|
|
||||||
"test": "mocha --reporter spec --check-leaks --bail test/",
|
|
||||||
"test-ci": "nyc --reporter=lcovonly --reporter=text npm test",
|
|
||||||
"test-cov": "nyc --reporter=html --reporter=text npm test",
|
|
||||||
"version": "node scripts/version-history.js && git add HISTORY.md"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
1.2.2 / 2024-10-29
|
|
||||||
==================
|
|
||||||
|
|
||||||
* various metadata/documentation tweaks (incl. #51)
|
|
||||||
|
|
||||||
|
|
||||||
1.2.1 / 2023-02-27
|
|
||||||
==================
|
|
||||||
|
|
||||||
* update annotations for allowed secret key types (#44, thanks @jyasskin!)
|
|
||||||
|
|
||||||
|
|
||||||
1.2.0 / 2022-02-17
|
|
||||||
==================
|
|
||||||
|
|
||||||
* allow buffer and other node-supported types as key (#33)
|
|
||||||
* be pickier about extra content after signed portion (#40)
|
|
||||||
* some internal code clarity/cleanup improvements (#26)
|
|
||||||
|
|
||||||
|
|
||||||
1.1.0 / 2018-01-18
|
|
||||||
==================
|
|
||||||
|
|
||||||
* switch to built-in `crypto.timingSafeEqual` for validation instead of previous double-hash method (thank you @jodevsa!)
|
|
||||||
|
|
||||||
|
|
||||||
1.0.7 / 2023-04-12
|
|
||||||
==================
|
|
||||||
|
|
||||||
Later release for older node.js versions. See the [v1.0.x branch notes](https://github.com/tj/node-cookie-signature/blob/v1.0.x/History.md#107--2023-04-12).
|
|
||||||
|
|
||||||
|
|
||||||
1.0.6 / 2015-02-03
|
|
||||||
==================
|
|
||||||
|
|
||||||
* use `npm test` instead of `make test` to run tests
|
|
||||||
* clearer assertion messages when checking input
|
|
||||||
|
|
||||||
|
|
||||||
1.0.5 / 2014-09-05
|
|
||||||
==================
|
|
||||||
|
|
||||||
* add license to package.json
|
|
||||||
|
|
||||||
1.0.4 / 2014-06-25
|
|
||||||
==================
|
|
||||||
|
|
||||||
* corrected avoidance of timing attacks (thanks @tenbits!)
|
|
||||||
|
|
||||||
1.0.3 / 2014-01-28
|
|
||||||
==================
|
|
||||||
|
|
||||||
* [incorrect] fix for timing attacks
|
|
||||||
|
|
||||||
1.0.2 / 2014-01-28
|
|
||||||
==================
|
|
||||||
|
|
||||||
* fix missing repository warning
|
|
||||||
* fix typo in test
|
|
||||||
|
|
||||||
1.0.1 / 2013-04-15
|
|
||||||
==================
|
|
||||||
|
|
||||||
* Revert "Changed underlying HMAC algo. to sha512."
|
|
||||||
* Revert "Fix for timing attacks on MAC verification."
|
|
||||||
|
|
||||||
0.0.1 / 2010-01-03
|
|
||||||
==================
|
|
||||||
|
|
||||||
* Initial release
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
(The MIT License)
|
|
||||||
|
|
||||||
Copyright (c) 2012–2024 LearnBoost <tj@learnboost.com> and other contributors;
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of this software and associated documentation files (the
|
|
||||||
'Software'), to deal in the Software without restriction, including
|
|
||||||
without limitation the rights to use, copy, modify, merge, publish,
|
|
||||||
distribute, sublicense, and/or sell copies of the Software, and to
|
|
||||||
permit persons to whom the Software is furnished to do so, subject to
|
|
||||||
the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be
|
|
||||||
included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
||||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
||||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
||||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
||||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
|
|
||||||
# cookie-signature
|
|
||||||
|
|
||||||
Sign and unsign cookies.
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
```js
|
|
||||||
var cookie = require('cookie-signature');
|
|
||||||
|
|
||||||
var val = cookie.sign('hello', 'tobiiscool');
|
|
||||||
val.should.equal('hello.DGDUkGlIkCzPz+C0B064FNgHdEjox7ch8tOBGslZ5QI');
|
|
||||||
|
|
||||||
var val = cookie.sign('hello', 'tobiiscool');
|
|
||||||
cookie.unsign(val, 'tobiiscool').should.equal('hello');
|
|
||||||
cookie.unsign(val, 'luna').should.be.false;
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT.
|
|
||||||
|
|
||||||
See LICENSE file for details.
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
/**
|
|
||||||
* Module dependencies.
|
|
||||||
*/
|
|
||||||
|
|
||||||
var crypto = require('crypto');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign the given `val` with `secret`.
|
|
||||||
*
|
|
||||||
* @param {String} val
|
|
||||||
* @param {String|NodeJS.ArrayBufferView|crypto.KeyObject} secret
|
|
||||||
* @return {String}
|
|
||||||
* @api private
|
|
||||||
*/
|
|
||||||
|
|
||||||
exports.sign = function(val, secret){
|
|
||||||
if ('string' != typeof val) throw new TypeError("Cookie value must be provided as a string.");
|
|
||||||
if (null == secret) throw new TypeError("Secret key must be provided.");
|
|
||||||
return val + '.' + crypto
|
|
||||||
.createHmac('sha256', secret)
|
|
||||||
.update(val)
|
|
||||||
.digest('base64')
|
|
||||||
.replace(/\=+$/, '');
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unsign and decode the given `input` with `secret`,
|
|
||||||
* returning `false` if the signature is invalid.
|
|
||||||
*
|
|
||||||
* @param {String} input
|
|
||||||
* @param {String|NodeJS.ArrayBufferView|crypto.KeyObject} secret
|
|
||||||
* @return {String|Boolean}
|
|
||||||
* @api private
|
|
||||||
*/
|
|
||||||
|
|
||||||
exports.unsign = function(input, secret){
|
|
||||||
if ('string' != typeof input) throw new TypeError("Signed cookie string must be provided.");
|
|
||||||
if (null == secret) throw new TypeError("Secret key must be provided.");
|
|
||||||
var tentativeValue = input.slice(0, input.lastIndexOf('.')),
|
|
||||||
expectedInput = exports.sign(tentativeValue, secret),
|
|
||||||
expectedBuffer = Buffer.from(expectedInput),
|
|
||||||
inputBuffer = Buffer.from(input);
|
|
||||||
return (
|
|
||||||
expectedBuffer.length === inputBuffer.length &&
|
|
||||||
crypto.timingSafeEqual(expectedBuffer, inputBuffer)
|
|
||||||
) ? tentativeValue : false;
|
|
||||||
};
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
{
|
|
||||||
"name": "cookie-signature",
|
|
||||||
"version": "1.2.2",
|
|
||||||
"main": "index.js",
|
|
||||||
"description": "Sign and unsign cookies",
|
|
||||||
"keywords": ["cookie", "sign", "unsign"],
|
|
||||||
"author": "TJ Holowaychuk <tj@learnboost.com>",
|
|
||||||
"license": "MIT",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/visionmedia/node-cookie-signature.git"
|
|
||||||
},
|
|
||||||
"dependencies": {},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.6.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"mocha": "*",
|
|
||||||
"should": "*"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"test": "mocha --require should --reporter spec"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
(The MIT License)
|
|
||||||
|
|
||||||
Copyright (c) 2012-2014 Roman Shtylman <shtylman@gmail.com>
|
|
||||||
Copyright (c) 2015 Douglas Christopher Wilson <doug@somethingdoug.com>
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of this software and associated documentation files (the
|
|
||||||
'Software'), to deal in the Software without restriction, including
|
|
||||||
without limitation the rights to use, copy, modify, merge, publish,
|
|
||||||
distribute, sublicense, and/or sell copies of the Software, and to
|
|
||||||
permit persons to whom the Software is furnished to do so, subject to
|
|
||||||
the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be
|
|
||||||
included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
||||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
||||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
||||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
||||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
|
|
||||||
|
|
@ -1,317 +0,0 @@
|
||||||
# cookie
|
|
||||||
|
|
||||||
[![NPM Version][npm-version-image]][npm-url]
|
|
||||||
[![NPM Downloads][npm-downloads-image]][npm-url]
|
|
||||||
[![Node.js Version][node-image]][node-url]
|
|
||||||
[![Build Status][ci-image]][ci-url]
|
|
||||||
[![Coverage Status][coveralls-image]][coveralls-url]
|
|
||||||
|
|
||||||
Basic HTTP cookie parser and serializer for HTTP servers.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
This is a [Node.js](https://nodejs.org/en/) module available through the
|
|
||||||
[npm registry](https://www.npmjs.com/). Installation is done using the
|
|
||||||
[`npm install` command](https://docs.npmjs.com/getting-started/installing-npm-packages-locally):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ npm install cookie
|
|
||||||
```
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
```js
|
|
||||||
var cookie = require('cookie');
|
|
||||||
```
|
|
||||||
|
|
||||||
### cookie.parse(str, options)
|
|
||||||
|
|
||||||
Parse an HTTP `Cookie` header string and returning an object of all cookie name-value pairs.
|
|
||||||
The `str` argument is the string representing a `Cookie` header value and `options` is an
|
|
||||||
optional object containing additional parsing options.
|
|
||||||
|
|
||||||
```js
|
|
||||||
var cookies = cookie.parse('foo=bar; equation=E%3Dmc%5E2');
|
|
||||||
// { foo: 'bar', equation: 'E=mc^2' }
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Options
|
|
||||||
|
|
||||||
`cookie.parse` accepts these properties in the options object.
|
|
||||||
|
|
||||||
##### decode
|
|
||||||
|
|
||||||
Specifies a function that will be used to decode a cookie's value. Since the value of a cookie
|
|
||||||
has a limited character set (and must be a simple string), this function can be used to decode
|
|
||||||
a previously-encoded cookie value into a JavaScript string or other object.
|
|
||||||
|
|
||||||
The default function is the global `decodeURIComponent`, which will decode any URL-encoded
|
|
||||||
sequences into their byte representations.
|
|
||||||
|
|
||||||
**note** if an error is thrown from this function, the original, non-decoded cookie value will
|
|
||||||
be returned as the cookie's value.
|
|
||||||
|
|
||||||
### cookie.serialize(name, value, options)
|
|
||||||
|
|
||||||
Serialize a cookie name-value pair into a `Set-Cookie` header string. The `name` argument is the
|
|
||||||
name for the cookie, the `value` argument is the value to set the cookie to, and the `options`
|
|
||||||
argument is an optional object containing additional serialization options.
|
|
||||||
|
|
||||||
```js
|
|
||||||
var setCookie = cookie.serialize('foo', 'bar');
|
|
||||||
// foo=bar
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Options
|
|
||||||
|
|
||||||
`cookie.serialize` accepts these properties in the options object.
|
|
||||||
|
|
||||||
##### domain
|
|
||||||
|
|
||||||
Specifies the value for the [`Domain` `Set-Cookie` attribute][rfc-6265-5.2.3]. By default, no
|
|
||||||
domain is set, and most clients will consider the cookie to apply to only the current domain.
|
|
||||||
|
|
||||||
##### encode
|
|
||||||
|
|
||||||
Specifies a function that will be used to encode a cookie's value. Since value of a cookie
|
|
||||||
has a limited character set (and must be a simple string), this function can be used to encode
|
|
||||||
a value into a string suited for a cookie's value.
|
|
||||||
|
|
||||||
The default function is the global `encodeURIComponent`, which will encode a JavaScript string
|
|
||||||
into UTF-8 byte sequences and then URL-encode any that fall outside of the cookie range.
|
|
||||||
|
|
||||||
##### expires
|
|
||||||
|
|
||||||
Specifies the `Date` object to be the value for the [`Expires` `Set-Cookie` attribute][rfc-6265-5.2.1].
|
|
||||||
By default, no expiration is set, and most clients will consider this a "non-persistent cookie" and
|
|
||||||
will delete it on a condition like exiting a web browser application.
|
|
||||||
|
|
||||||
**note** the [cookie storage model specification][rfc-6265-5.3] states that if both `expires` and
|
|
||||||
`maxAge` are set, then `maxAge` takes precedence, but it is possible not all clients by obey this,
|
|
||||||
so if both are set, they should point to the same date and time.
|
|
||||||
|
|
||||||
##### httpOnly
|
|
||||||
|
|
||||||
Specifies the `boolean` value for the [`HttpOnly` `Set-Cookie` attribute][rfc-6265-5.2.6]. When truthy,
|
|
||||||
the `HttpOnly` attribute is set, otherwise it is not. By default, the `HttpOnly` attribute is not set.
|
|
||||||
|
|
||||||
**note** be careful when setting this to `true`, as compliant clients will not allow client-side
|
|
||||||
JavaScript to see the cookie in `document.cookie`.
|
|
||||||
|
|
||||||
##### maxAge
|
|
||||||
|
|
||||||
Specifies the `number` (in seconds) to be the value for the [`Max-Age` `Set-Cookie` attribute][rfc-6265-5.2.2].
|
|
||||||
The given number will be converted to an integer by rounding down. By default, no maximum age is set.
|
|
||||||
|
|
||||||
**note** the [cookie storage model specification][rfc-6265-5.3] states that if both `expires` and
|
|
||||||
`maxAge` are set, then `maxAge` takes precedence, but it is possible not all clients by obey this,
|
|
||||||
so if both are set, they should point to the same date and time.
|
|
||||||
|
|
||||||
##### partitioned
|
|
||||||
|
|
||||||
Specifies the `boolean` value for the [`Partitioned` `Set-Cookie`](rfc-cutler-httpbis-partitioned-cookies)
|
|
||||||
attribute. When truthy, the `Partitioned` attribute is set, otherwise it is not. By default, the
|
|
||||||
`Partitioned` attribute is not set.
|
|
||||||
|
|
||||||
**note** This is an attribute that has not yet been fully standardized, and may change in the future.
|
|
||||||
This also means many clients may ignore this attribute until they understand it.
|
|
||||||
|
|
||||||
More information about can be found in [the proposal](https://github.com/privacycg/CHIPS).
|
|
||||||
|
|
||||||
##### path
|
|
||||||
|
|
||||||
Specifies the value for the [`Path` `Set-Cookie` attribute][rfc-6265-5.2.4]. By default, the path
|
|
||||||
is considered the ["default path"][rfc-6265-5.1.4].
|
|
||||||
|
|
||||||
##### priority
|
|
||||||
|
|
||||||
Specifies the `string` to be the value for the [`Priority` `Set-Cookie` attribute][rfc-west-cookie-priority-00-4.1].
|
|
||||||
|
|
||||||
- `'low'` will set the `Priority` attribute to `Low`.
|
|
||||||
- `'medium'` will set the `Priority` attribute to `Medium`, the default priority when not set.
|
|
||||||
- `'high'` will set the `Priority` attribute to `High`.
|
|
||||||
|
|
||||||
More information about the different priority levels can be found in
|
|
||||||
[the specification][rfc-west-cookie-priority-00-4.1].
|
|
||||||
|
|
||||||
**note** This is an attribute that has not yet been fully standardized, and may change in the future.
|
|
||||||
This also means many clients may ignore this attribute until they understand it.
|
|
||||||
|
|
||||||
##### sameSite
|
|
||||||
|
|
||||||
Specifies the `boolean` or `string` to be the value for the [`SameSite` `Set-Cookie` attribute][rfc-6265bis-09-5.4.7].
|
|
||||||
|
|
||||||
- `true` will set the `SameSite` attribute to `Strict` for strict same site enforcement.
|
|
||||||
- `false` will not set the `SameSite` attribute.
|
|
||||||
- `'lax'` will set the `SameSite` attribute to `Lax` for lax same site enforcement.
|
|
||||||
- `'none'` will set the `SameSite` attribute to `None` for an explicit cross-site cookie.
|
|
||||||
- `'strict'` will set the `SameSite` attribute to `Strict` for strict same site enforcement.
|
|
||||||
|
|
||||||
More information about the different enforcement levels can be found in
|
|
||||||
[the specification][rfc-6265bis-09-5.4.7].
|
|
||||||
|
|
||||||
**note** This is an attribute that has not yet been fully standardized, and may change in the future.
|
|
||||||
This also means many clients may ignore this attribute until they understand it.
|
|
||||||
|
|
||||||
##### secure
|
|
||||||
|
|
||||||
Specifies the `boolean` value for the [`Secure` `Set-Cookie` attribute][rfc-6265-5.2.5]. When truthy,
|
|
||||||
the `Secure` attribute is set, otherwise it is not. By default, the `Secure` attribute is not set.
|
|
||||||
|
|
||||||
**note** be careful when setting this to `true`, as compliant clients will not send the cookie back to
|
|
||||||
the server in the future if the browser does not have an HTTPS connection.
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
The following example uses this module in conjunction with the Node.js core HTTP server
|
|
||||||
to prompt a user for their name and display it back on future visits.
|
|
||||||
|
|
||||||
```js
|
|
||||||
var cookie = require('cookie');
|
|
||||||
var escapeHtml = require('escape-html');
|
|
||||||
var http = require('http');
|
|
||||||
var url = require('url');
|
|
||||||
|
|
||||||
function onRequest(req, res) {
|
|
||||||
// Parse the query string
|
|
||||||
var query = url.parse(req.url, true, true).query;
|
|
||||||
|
|
||||||
if (query && query.name) {
|
|
||||||
// Set a new cookie with the name
|
|
||||||
res.setHeader('Set-Cookie', cookie.serialize('name', String(query.name), {
|
|
||||||
httpOnly: true,
|
|
||||||
maxAge: 60 * 60 * 24 * 7 // 1 week
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Redirect back after setting cookie
|
|
||||||
res.statusCode = 302;
|
|
||||||
res.setHeader('Location', req.headers.referer || '/');
|
|
||||||
res.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the cookies on the request
|
|
||||||
var cookies = cookie.parse(req.headers.cookie || '');
|
|
||||||
|
|
||||||
// Get the visitor name set in the cookie
|
|
||||||
var name = cookies.name;
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/html; charset=UTF-8');
|
|
||||||
|
|
||||||
if (name) {
|
|
||||||
res.write('<p>Welcome back, <b>' + escapeHtml(name) + '</b>!</p>');
|
|
||||||
} else {
|
|
||||||
res.write('<p>Hello, new visitor!</p>');
|
|
||||||
}
|
|
||||||
|
|
||||||
res.write('<form method="GET">');
|
|
||||||
res.write('<input placeholder="enter your name" name="name"> <input type="submit" value="Set Name">');
|
|
||||||
res.end('</form>');
|
|
||||||
}
|
|
||||||
|
|
||||||
http.createServer(onRequest).listen(3000);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ npm test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Benchmark
|
|
||||||
|
|
||||||
```
|
|
||||||
$ npm run bench
|
|
||||||
|
|
||||||
> cookie@0.5.0 bench
|
|
||||||
> node benchmark/index.js
|
|
||||||
|
|
||||||
node@18.18.2
|
|
||||||
acorn@8.10.0
|
|
||||||
ada@2.6.0
|
|
||||||
ares@1.19.1
|
|
||||||
brotli@1.0.9
|
|
||||||
cldr@43.1
|
|
||||||
icu@73.2
|
|
||||||
llhttp@6.0.11
|
|
||||||
modules@108
|
|
||||||
napi@9
|
|
||||||
nghttp2@1.57.0
|
|
||||||
nghttp3@0.7.0
|
|
||||||
ngtcp2@0.8.1
|
|
||||||
openssl@3.0.10+quic
|
|
||||||
simdutf@3.2.14
|
|
||||||
tz@2023c
|
|
||||||
undici@5.26.3
|
|
||||||
unicode@15.0
|
|
||||||
uv@1.44.2
|
|
||||||
uvwasi@0.0.18
|
|
||||||
v8@10.2.154.26-node.26
|
|
||||||
zlib@1.2.13.1-motley
|
|
||||||
|
|
||||||
> node benchmark/parse-top.js
|
|
||||||
|
|
||||||
cookie.parse - top sites
|
|
||||||
|
|
||||||
14 tests completed.
|
|
||||||
|
|
||||||
parse accounts.google.com x 2,588,913 ops/sec ±0.74% (186 runs sampled)
|
|
||||||
parse apple.com x 2,370,002 ops/sec ±0.69% (186 runs sampled)
|
|
||||||
parse cloudflare.com x 2,213,102 ops/sec ±0.88% (188 runs sampled)
|
|
||||||
parse docs.google.com x 2,194,157 ops/sec ±1.03% (184 runs sampled)
|
|
||||||
parse drive.google.com x 2,265,084 ops/sec ±0.79% (187 runs sampled)
|
|
||||||
parse en.wikipedia.org x 457,099 ops/sec ±0.81% (186 runs sampled)
|
|
||||||
parse linkedin.com x 504,407 ops/sec ±0.89% (186 runs sampled)
|
|
||||||
parse maps.google.com x 1,230,959 ops/sec ±0.98% (186 runs sampled)
|
|
||||||
parse microsoft.com x 926,294 ops/sec ±0.88% (184 runs sampled)
|
|
||||||
parse play.google.com x 2,311,338 ops/sec ±0.83% (185 runs sampled)
|
|
||||||
parse support.google.com x 1,508,850 ops/sec ±0.86% (186 runs sampled)
|
|
||||||
parse www.google.com x 1,022,582 ops/sec ±1.32% (182 runs sampled)
|
|
||||||
parse youtu.be x 332,136 ops/sec ±1.02% (185 runs sampled)
|
|
||||||
parse youtube.com x 323,833 ops/sec ±0.77% (183 runs sampled)
|
|
||||||
|
|
||||||
> node benchmark/parse.js
|
|
||||||
|
|
||||||
cookie.parse - generic
|
|
||||||
|
|
||||||
6 tests completed.
|
|
||||||
|
|
||||||
simple x 3,214,032 ops/sec ±1.61% (183 runs sampled)
|
|
||||||
decode x 587,237 ops/sec ±1.16% (187 runs sampled)
|
|
||||||
unquote x 2,954,618 ops/sec ±1.35% (183 runs sampled)
|
|
||||||
duplicates x 857,008 ops/sec ±0.89% (187 runs sampled)
|
|
||||||
10 cookies x 292,133 ops/sec ±0.89% (187 runs sampled)
|
|
||||||
100 cookies x 22,610 ops/sec ±0.68% (187 runs sampled)
|
|
||||||
```
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [RFC 6265: HTTP State Management Mechanism][rfc-6265]
|
|
||||||
- [Same-site Cookies][rfc-6265bis-09-5.4.7]
|
|
||||||
|
|
||||||
[rfc-cutler-httpbis-partitioned-cookies]: https://tools.ietf.org/html/draft-cutler-httpbis-partitioned-cookies/
|
|
||||||
[rfc-west-cookie-priority-00-4.1]: https://tools.ietf.org/html/draft-west-cookie-priority-00#section-4.1
|
|
||||||
[rfc-6265bis-09-5.4.7]: https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-09#section-5.4.7
|
|
||||||
[rfc-6265]: https://tools.ietf.org/html/rfc6265
|
|
||||||
[rfc-6265-5.1.4]: https://tools.ietf.org/html/rfc6265#section-5.1.4
|
|
||||||
[rfc-6265-5.2.1]: https://tools.ietf.org/html/rfc6265#section-5.2.1
|
|
||||||
[rfc-6265-5.2.2]: https://tools.ietf.org/html/rfc6265#section-5.2.2
|
|
||||||
[rfc-6265-5.2.3]: https://tools.ietf.org/html/rfc6265#section-5.2.3
|
|
||||||
[rfc-6265-5.2.4]: https://tools.ietf.org/html/rfc6265#section-5.2.4
|
|
||||||
[rfc-6265-5.2.5]: https://tools.ietf.org/html/rfc6265#section-5.2.5
|
|
||||||
[rfc-6265-5.2.6]: https://tools.ietf.org/html/rfc6265#section-5.2.6
|
|
||||||
[rfc-6265-5.3]: https://tools.ietf.org/html/rfc6265#section-5.3
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[MIT](LICENSE)
|
|
||||||
|
|
||||||
[ci-image]: https://badgen.net/github/checks/jshttp/cookie/master?label=ci
|
|
||||||
[ci-url]: https://github.com/jshttp/cookie/actions/workflows/ci.yml
|
|
||||||
[coveralls-image]: https://badgen.net/coveralls/c/github/jshttp/cookie/master
|
|
||||||
[coveralls-url]: https://coveralls.io/r/jshttp/cookie?branch=master
|
|
||||||
[node-image]: https://badgen.net/npm/node/cookie
|
|
||||||
[node-url]: https://nodejs.org/en/download
|
|
||||||
[npm-downloads-image]: https://badgen.net/npm/dm/cookie
|
|
||||||
[npm-url]: https://npmjs.org/package/cookie
|
|
||||||
[npm-version-image]: https://badgen.net/npm/v/cookie
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
# Security Policies and Procedures
|
|
||||||
|
|
||||||
## Reporting a Bug
|
|
||||||
|
|
||||||
The `cookie` team and community take all security bugs seriously. Thank
|
|
||||||
you for improving the security of the project. We appreciate your efforts and
|
|
||||||
responsible disclosure and will make every effort to acknowledge your
|
|
||||||
contributions.
|
|
||||||
|
|
||||||
Report security bugs by emailing the current owner(s) of `cookie`. This
|
|
||||||
information can be found in the npm registry using the command
|
|
||||||
`npm owner ls cookie`.
|
|
||||||
If unsure or unable to get the information from the above, open an issue
|
|
||||||
in the [project issue tracker](https://github.com/jshttp/cookie/issues)
|
|
||||||
asking for the current contact information.
|
|
||||||
|
|
||||||
To ensure the timely response to your report, please ensure that the entirety
|
|
||||||
of the report is contained within the email body and not solely behind a web
|
|
||||||
link or an attachment.
|
|
||||||
|
|
||||||
At least one owner will acknowledge your email within 48 hours, and will send a
|
|
||||||
more detailed response within 48 hours indicating the next steps in handling
|
|
||||||
your report. After the initial reply to your report, the owners will
|
|
||||||
endeavor to keep you informed of the progress towards a fix and full
|
|
||||||
announcement, and may ask for additional information or guidance.
|
|
||||||
|
|
@ -1,335 +0,0 @@
|
||||||
/*!
|
|
||||||
* cookie
|
|
||||||
* Copyright(c) 2012-2014 Roman Shtylman
|
|
||||||
* Copyright(c) 2015 Douglas Christopher Wilson
|
|
||||||
* MIT Licensed
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module exports.
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
exports.parse = parse;
|
|
||||||
exports.serialize = serialize;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module variables.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
var __toString = Object.prototype.toString
|
|
||||||
var __hasOwnProperty = Object.prototype.hasOwnProperty
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RegExp to match cookie-name in RFC 6265 sec 4.1.1
|
|
||||||
* This refers out to the obsoleted definition of token in RFC 2616 sec 2.2
|
|
||||||
* which has been replaced by the token definition in RFC 7230 appendix B.
|
|
||||||
*
|
|
||||||
* cookie-name = token
|
|
||||||
* token = 1*tchar
|
|
||||||
* tchar = "!" / "#" / "$" / "%" / "&" / "'" /
|
|
||||||
* "*" / "+" / "-" / "." / "^" / "_" /
|
|
||||||
* "`" / "|" / "~" / DIGIT / ALPHA
|
|
||||||
*/
|
|
||||||
|
|
||||||
var cookieNameRegExp = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RegExp to match cookie-value in RFC 6265 sec 4.1.1
|
|
||||||
*
|
|
||||||
* cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
|
|
||||||
* cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
|
|
||||||
* ; US-ASCII characters excluding CTLs,
|
|
||||||
* ; whitespace DQUOTE, comma, semicolon,
|
|
||||||
* ; and backslash
|
|
||||||
*/
|
|
||||||
|
|
||||||
var cookieValueRegExp = /^("?)[\u0021\u0023-\u002B\u002D-\u003A\u003C-\u005B\u005D-\u007E]*\1$/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RegExp to match domain-value in RFC 6265 sec 4.1.1
|
|
||||||
*
|
|
||||||
* domain-value = <subdomain>
|
|
||||||
* ; defined in [RFC1034], Section 3.5, as
|
|
||||||
* ; enhanced by [RFC1123], Section 2.1
|
|
||||||
* <subdomain> = <label> | <subdomain> "." <label>
|
|
||||||
* <label> = <let-dig> [ [ <ldh-str> ] <let-dig> ]
|
|
||||||
* Labels must be 63 characters or less.
|
|
||||||
* 'let-dig' not 'letter' in the first char, per RFC1123
|
|
||||||
* <ldh-str> = <let-dig-hyp> | <let-dig-hyp> <ldh-str>
|
|
||||||
* <let-dig-hyp> = <let-dig> | "-"
|
|
||||||
* <let-dig> = <letter> | <digit>
|
|
||||||
* <letter> = any one of the 52 alphabetic characters A through Z in
|
|
||||||
* upper case and a through z in lower case
|
|
||||||
* <digit> = any one of the ten digits 0 through 9
|
|
||||||
*
|
|
||||||
* Keep support for leading dot: https://github.com/jshttp/cookie/issues/173
|
|
||||||
*
|
|
||||||
* > (Note that a leading %x2E ("."), if present, is ignored even though that
|
|
||||||
* character is not permitted, but a trailing %x2E ("."), if present, will
|
|
||||||
* cause the user agent to ignore the attribute.)
|
|
||||||
*/
|
|
||||||
|
|
||||||
var domainValueRegExp = /^([.]?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)([.][a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RegExp to match path-value in RFC 6265 sec 4.1.1
|
|
||||||
*
|
|
||||||
* path-value = <any CHAR except CTLs or ";">
|
|
||||||
* CHAR = %x01-7F
|
|
||||||
* ; defined in RFC 5234 appendix B.1
|
|
||||||
*/
|
|
||||||
|
|
||||||
var pathValueRegExp = /^[\u0020-\u003A\u003D-\u007E]*$/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a cookie header.
|
|
||||||
*
|
|
||||||
* Parse the given cookie header string into an object
|
|
||||||
* The object has the various cookies as keys(names) => values
|
|
||||||
*
|
|
||||||
* @param {string} str
|
|
||||||
* @param {object} [opt]
|
|
||||||
* @return {object}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
function parse(str, opt) {
|
|
||||||
if (typeof str !== 'string') {
|
|
||||||
throw new TypeError('argument str must be a string');
|
|
||||||
}
|
|
||||||
|
|
||||||
var obj = {};
|
|
||||||
var len = str.length;
|
|
||||||
// RFC 6265 sec 4.1.1, RFC 2616 2.2 defines a cookie name consists of one char minimum, plus '='.
|
|
||||||
if (len < 2) return obj;
|
|
||||||
|
|
||||||
var dec = (opt && opt.decode) || decode;
|
|
||||||
var index = 0;
|
|
||||||
var eqIdx = 0;
|
|
||||||
var endIdx = 0;
|
|
||||||
|
|
||||||
do {
|
|
||||||
eqIdx = str.indexOf('=', index);
|
|
||||||
if (eqIdx === -1) break; // No more cookie pairs.
|
|
||||||
|
|
||||||
endIdx = str.indexOf(';', index);
|
|
||||||
|
|
||||||
if (endIdx === -1) {
|
|
||||||
endIdx = len;
|
|
||||||
} else if (eqIdx > endIdx) {
|
|
||||||
// backtrack on prior semicolon
|
|
||||||
index = str.lastIndexOf(';', eqIdx - 1) + 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var keyStartIdx = startIndex(str, index, eqIdx);
|
|
||||||
var keyEndIdx = endIndex(str, eqIdx, keyStartIdx);
|
|
||||||
var key = str.slice(keyStartIdx, keyEndIdx);
|
|
||||||
|
|
||||||
// only assign once
|
|
||||||
if (!__hasOwnProperty.call(obj, key)) {
|
|
||||||
var valStartIdx = startIndex(str, eqIdx + 1, endIdx);
|
|
||||||
var valEndIdx = endIndex(str, endIdx, valStartIdx);
|
|
||||||
|
|
||||||
if (str.charCodeAt(valStartIdx) === 0x22 /* " */ && str.charCodeAt(valEndIdx - 1) === 0x22 /* " */) {
|
|
||||||
valStartIdx++;
|
|
||||||
valEndIdx--;
|
|
||||||
}
|
|
||||||
|
|
||||||
var val = str.slice(valStartIdx, valEndIdx);
|
|
||||||
obj[key] = tryDecode(val, dec);
|
|
||||||
}
|
|
||||||
|
|
||||||
index = endIdx + 1
|
|
||||||
} while (index < len);
|
|
||||||
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
function startIndex(str, index, max) {
|
|
||||||
do {
|
|
||||||
var code = str.charCodeAt(index);
|
|
||||||
if (code !== 0x20 /* */ && code !== 0x09 /* \t */) return index;
|
|
||||||
} while (++index < max);
|
|
||||||
return max;
|
|
||||||
}
|
|
||||||
|
|
||||||
function endIndex(str, index, min) {
|
|
||||||
while (index > min) {
|
|
||||||
var code = str.charCodeAt(--index);
|
|
||||||
if (code !== 0x20 /* */ && code !== 0x09 /* \t */) return index + 1;
|
|
||||||
}
|
|
||||||
return min;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serialize data into a cookie header.
|
|
||||||
*
|
|
||||||
* Serialize a name value pair into a cookie string suitable for
|
|
||||||
* http headers. An optional options object specifies cookie parameters.
|
|
||||||
*
|
|
||||||
* serialize('foo', 'bar', { httpOnly: true })
|
|
||||||
* => "foo=bar; httpOnly"
|
|
||||||
*
|
|
||||||
* @param {string} name
|
|
||||||
* @param {string} val
|
|
||||||
* @param {object} [opt]
|
|
||||||
* @return {string}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
function serialize(name, val, opt) {
|
|
||||||
var enc = (opt && opt.encode) || encodeURIComponent;
|
|
||||||
|
|
||||||
if (typeof enc !== 'function') {
|
|
||||||
throw new TypeError('option encode is invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cookieNameRegExp.test(name)) {
|
|
||||||
throw new TypeError('argument name is invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
var value = enc(val);
|
|
||||||
|
|
||||||
if (!cookieValueRegExp.test(value)) {
|
|
||||||
throw new TypeError('argument val is invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
var str = name + '=' + value;
|
|
||||||
if (!opt) return str;
|
|
||||||
|
|
||||||
if (null != opt.maxAge) {
|
|
||||||
var maxAge = Math.floor(opt.maxAge);
|
|
||||||
|
|
||||||
if (!isFinite(maxAge)) {
|
|
||||||
throw new TypeError('option maxAge is invalid')
|
|
||||||
}
|
|
||||||
|
|
||||||
str += '; Max-Age=' + maxAge;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opt.domain) {
|
|
||||||
if (!domainValueRegExp.test(opt.domain)) {
|
|
||||||
throw new TypeError('option domain is invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
str += '; Domain=' + opt.domain;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opt.path) {
|
|
||||||
if (!pathValueRegExp.test(opt.path)) {
|
|
||||||
throw new TypeError('option path is invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
str += '; Path=' + opt.path;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opt.expires) {
|
|
||||||
var expires = opt.expires
|
|
||||||
|
|
||||||
if (!isDate(expires) || isNaN(expires.valueOf())) {
|
|
||||||
throw new TypeError('option expires is invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
str += '; Expires=' + expires.toUTCString()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opt.httpOnly) {
|
|
||||||
str += '; HttpOnly';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opt.secure) {
|
|
||||||
str += '; Secure';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opt.partitioned) {
|
|
||||||
str += '; Partitioned'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opt.priority) {
|
|
||||||
var priority = typeof opt.priority === 'string'
|
|
||||||
? opt.priority.toLowerCase() : opt.priority;
|
|
||||||
|
|
||||||
switch (priority) {
|
|
||||||
case 'low':
|
|
||||||
str += '; Priority=Low'
|
|
||||||
break
|
|
||||||
case 'medium':
|
|
||||||
str += '; Priority=Medium'
|
|
||||||
break
|
|
||||||
case 'high':
|
|
||||||
str += '; Priority=High'
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
throw new TypeError('option priority is invalid')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opt.sameSite) {
|
|
||||||
var sameSite = typeof opt.sameSite === 'string'
|
|
||||||
? opt.sameSite.toLowerCase() : opt.sameSite;
|
|
||||||
|
|
||||||
switch (sameSite) {
|
|
||||||
case true:
|
|
||||||
str += '; SameSite=Strict';
|
|
||||||
break;
|
|
||||||
case 'lax':
|
|
||||||
str += '; SameSite=Lax';
|
|
||||||
break;
|
|
||||||
case 'strict':
|
|
||||||
str += '; SameSite=Strict';
|
|
||||||
break;
|
|
||||||
case 'none':
|
|
||||||
str += '; SameSite=None';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new TypeError('option sameSite is invalid');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return str;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* URL-decode string value. Optimized to skip native call when no %.
|
|
||||||
*
|
|
||||||
* @param {string} str
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
|
|
||||||
function decode (str) {
|
|
||||||
return str.indexOf('%') !== -1
|
|
||||||
? decodeURIComponent(str)
|
|
||||||
: str
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine if value is a Date.
|
|
||||||
*
|
|
||||||
* @param {*} val
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
function isDate (val) {
|
|
||||||
return __toString.call(val) === '[object Date]';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try decoding a string using a decoding function.
|
|
||||||
*
|
|
||||||
* @param {string} str
|
|
||||||
* @param {function} decode
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
function tryDecode(str, decode) {
|
|
||||||
try {
|
|
||||||
return decode(str);
|
|
||||||
} catch (e) {
|
|
||||||
return str;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
{
|
|
||||||
"name": "cookie",
|
|
||||||
"description": "HTTP server cookie parsing and serialization",
|
|
||||||
"version": "0.7.2",
|
|
||||||
"author": "Roman Shtylman <shtylman@gmail.com>",
|
|
||||||
"contributors": [
|
|
||||||
"Douglas Christopher Wilson <doug@somethingdoug.com>"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"keywords": [
|
|
||||||
"cookie",
|
|
||||||
"cookies"
|
|
||||||
],
|
|
||||||
"repository": "jshttp/cookie",
|
|
||||||
"devDependencies": {
|
|
||||||
"beautify-benchmark": "0.2.4",
|
|
||||||
"benchmark": "2.1.4",
|
|
||||||
"eslint": "8.53.0",
|
|
||||||
"eslint-plugin-markdown": "3.0.1",
|
|
||||||
"mocha": "10.2.0",
|
|
||||||
"nyc": "15.1.0",
|
|
||||||
"safe-buffer": "5.2.1",
|
|
||||||
"top-sites": "1.1.194"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"HISTORY.md",
|
|
||||||
"LICENSE",
|
|
||||||
"README.md",
|
|
||||||
"SECURITY.md",
|
|
||||||
"index.js"
|
|
||||||
],
|
|
||||||
"main": "index.js",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"bench": "node benchmark/index.js",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"test": "mocha --reporter spec --bail --check-leaks test/",
|
|
||||||
"test-ci": "nyc --reporter=lcov --reporter=text npm test",
|
|
||||||
"test-cov": "nyc --reporter=html --reporter=text npm test",
|
|
||||||
"update-bench": "node scripts/update-benchmark.js"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
(The MIT License)
|
|
||||||
|
|
||||||
Copyright (c) 2014-2017 TJ Holowaychuk <tj@vision-media.ca>
|
|
||||||
Copyright (c) 2018-2021 Josh Junon
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
|
||||||
and associated documentation files (the 'Software'), to deal in the Software without restriction,
|
|
||||||
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
||||||
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
|
|
||||||
subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
|
||||||
portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
|
||||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
||||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
||||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
||||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
|
|
||||||
|
|
@ -1,481 +0,0 @@
|
||||||
# debug
|
|
||||||
[](#backers)
|
|
||||||
[](#sponsors)
|
|
||||||
|
|
||||||
<img width="647" src="https://user-images.githubusercontent.com/71256/29091486-fa38524c-7c37-11e7-895f-e7ec8e1039b6.png">
|
|
||||||
|
|
||||||
A tiny JavaScript debugging utility modelled after Node.js core's debugging
|
|
||||||
technique. Works in Node.js and web browsers.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ npm install debug
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
`debug` exposes a function; simply pass this function the name of your module, and it will return a decorated version of `console.error` for you to pass debug statements to. This will allow you to toggle the debug output for different parts of your module as well as the module as a whole.
|
|
||||||
|
|
||||||
Example [_app.js_](./examples/node/app.js):
|
|
||||||
|
|
||||||
```js
|
|
||||||
var debug = require('debug')('http')
|
|
||||||
, http = require('http')
|
|
||||||
, name = 'My App';
|
|
||||||
|
|
||||||
// fake app
|
|
||||||
|
|
||||||
debug('booting %o', name);
|
|
||||||
|
|
||||||
http.createServer(function(req, res){
|
|
||||||
debug(req.method + ' ' + req.url);
|
|
||||||
res.end('hello\n');
|
|
||||||
}).listen(3000, function(){
|
|
||||||
debug('listening');
|
|
||||||
});
|
|
||||||
|
|
||||||
// fake worker of some kind
|
|
||||||
|
|
||||||
require('./worker');
|
|
||||||
```
|
|
||||||
|
|
||||||
Example [_worker.js_](./examples/node/worker.js):
|
|
||||||
|
|
||||||
```js
|
|
||||||
var a = require('debug')('worker:a')
|
|
||||||
, b = require('debug')('worker:b');
|
|
||||||
|
|
||||||
function work() {
|
|
||||||
a('doing lots of uninteresting work');
|
|
||||||
setTimeout(work, Math.random() * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
work();
|
|
||||||
|
|
||||||
function workb() {
|
|
||||||
b('doing some work');
|
|
||||||
setTimeout(workb, Math.random() * 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
workb();
|
|
||||||
```
|
|
||||||
|
|
||||||
The `DEBUG` environment variable is then used to enable these based on space or
|
|
||||||
comma-delimited names.
|
|
||||||
|
|
||||||
Here are some examples:
|
|
||||||
|
|
||||||
<img width="647" alt="screen shot 2017-08-08 at 12 53 04 pm" src="https://user-images.githubusercontent.com/71256/29091703-a6302cdc-7c38-11e7-8304-7c0b3bc600cd.png">
|
|
||||||
<img width="647" alt="screen shot 2017-08-08 at 12 53 38 pm" src="https://user-images.githubusercontent.com/71256/29091700-a62a6888-7c38-11e7-800b-db911291ca2b.png">
|
|
||||||
<img width="647" alt="screen shot 2017-08-08 at 12 53 25 pm" src="https://user-images.githubusercontent.com/71256/29091701-a62ea114-7c38-11e7-826a-2692bedca740.png">
|
|
||||||
|
|
||||||
#### Windows command prompt notes
|
|
||||||
|
|
||||||
##### CMD
|
|
||||||
|
|
||||||
On Windows the environment variable is set using the `set` command.
|
|
||||||
|
|
||||||
```cmd
|
|
||||||
set DEBUG=*,-not_this
|
|
||||||
```
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```cmd
|
|
||||||
set DEBUG=* & node app.js
|
|
||||||
```
|
|
||||||
|
|
||||||
##### PowerShell (VS Code default)
|
|
||||||
|
|
||||||
PowerShell uses different syntax to set environment variables.
|
|
||||||
|
|
||||||
```cmd
|
|
||||||
$env:DEBUG = "*,-not_this"
|
|
||||||
```
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```cmd
|
|
||||||
$env:DEBUG='app';node app.js
|
|
||||||
```
|
|
||||||
|
|
||||||
Then, run the program to be debugged as usual.
|
|
||||||
|
|
||||||
npm script example:
|
|
||||||
```js
|
|
||||||
"windowsDebug": "@powershell -Command $env:DEBUG='*';node app.js",
|
|
||||||
```
|
|
||||||
|
|
||||||
## Namespace Colors
|
|
||||||
|
|
||||||
Every debug instance has a color generated for it based on its namespace name.
|
|
||||||
This helps when visually parsing the debug output to identify which debug instance
|
|
||||||
a debug line belongs to.
|
|
||||||
|
|
||||||
#### Node.js
|
|
||||||
|
|
||||||
In Node.js, colors are enabled when stderr is a TTY. You also _should_ install
|
|
||||||
the [`supports-color`](https://npmjs.org/supports-color) module alongside debug,
|
|
||||||
otherwise debug will only use a small handful of basic colors.
|
|
||||||
|
|
||||||
<img width="521" src="https://user-images.githubusercontent.com/71256/29092181-47f6a9e6-7c3a-11e7-9a14-1928d8a711cd.png">
|
|
||||||
|
|
||||||
#### Web Browser
|
|
||||||
|
|
||||||
Colors are also enabled on "Web Inspectors" that understand the `%c` formatting
|
|
||||||
option. These are WebKit web inspectors, Firefox ([since version
|
|
||||||
31](https://hacks.mozilla.org/2014/05/editable-box-model-multiple-selection-sublime-text-keys-much-more-firefox-developer-tools-episode-31/))
|
|
||||||
and the Firebug plugin for Firefox (any version).
|
|
||||||
|
|
||||||
<img width="524" src="https://user-images.githubusercontent.com/71256/29092033-b65f9f2e-7c39-11e7-8e32-f6f0d8e865c1.png">
|
|
||||||
|
|
||||||
|
|
||||||
## Millisecond diff
|
|
||||||
|
|
||||||
When actively developing an application it can be useful to see when the time spent between one `debug()` call and the next. Suppose for example you invoke `debug()` before requesting a resource, and after as well, the "+NNNms" will show you how much time was spent between calls.
|
|
||||||
|
|
||||||
<img width="647" src="https://user-images.githubusercontent.com/71256/29091486-fa38524c-7c37-11e7-895f-e7ec8e1039b6.png">
|
|
||||||
|
|
||||||
When stdout is not a TTY, `Date#toISOString()` is used, making it more useful for logging the debug information as shown below:
|
|
||||||
|
|
||||||
<img width="647" src="https://user-images.githubusercontent.com/71256/29091956-6bd78372-7c39-11e7-8c55-c948396d6edd.png">
|
|
||||||
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
If you're using this in one or more of your libraries, you _should_ use the name of your library so that developers may toggle debugging as desired without guessing names. If you have more than one debuggers you _should_ prefix them with your library name and use ":" to separate features. For example "bodyParser" from Connect would then be "connect:bodyParser". If you append a "*" to the end of your name, it will always be enabled regardless of the setting of the DEBUG environment variable. You can then use it for normal output as well as debug output.
|
|
||||||
|
|
||||||
## Wildcards
|
|
||||||
|
|
||||||
The `*` character may be used as a wildcard. Suppose for example your library has
|
|
||||||
debuggers named "connect:bodyParser", "connect:compress", "connect:session",
|
|
||||||
instead of listing all three with
|
|
||||||
`DEBUG=connect:bodyParser,connect:compress,connect:session`, you may simply do
|
|
||||||
`DEBUG=connect:*`, or to run everything using this module simply use `DEBUG=*`.
|
|
||||||
|
|
||||||
You can also exclude specific debuggers by prefixing them with a "-" character.
|
|
||||||
For example, `DEBUG=*,-connect:*` would include all debuggers except those
|
|
||||||
starting with "connect:".
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
When running through Node.js, you can set a few environment variables that will
|
|
||||||
change the behavior of the debug logging:
|
|
||||||
|
|
||||||
| Name | Purpose |
|
|
||||||
|-----------|-------------------------------------------------|
|
|
||||||
| `DEBUG` | Enables/disables specific debugging namespaces. |
|
|
||||||
| `DEBUG_HIDE_DATE` | Hide date from debug output (non-TTY). |
|
|
||||||
| `DEBUG_COLORS`| Whether or not to use colors in the debug output. |
|
|
||||||
| `DEBUG_DEPTH` | Object inspection depth. |
|
|
||||||
| `DEBUG_SHOW_HIDDEN` | Shows hidden properties on inspected objects. |
|
|
||||||
|
|
||||||
|
|
||||||
__Note:__ The environment variables beginning with `DEBUG_` end up being
|
|
||||||
converted into an Options object that gets used with `%o`/`%O` formatters.
|
|
||||||
See the Node.js documentation for
|
|
||||||
[`util.inspect()`](https://nodejs.org/api/util.html#util_util_inspect_object_options)
|
|
||||||
for the complete list.
|
|
||||||
|
|
||||||
## Formatters
|
|
||||||
|
|
||||||
Debug uses [printf-style](https://wikipedia.org/wiki/Printf_format_string) formatting.
|
|
||||||
Below are the officially supported formatters:
|
|
||||||
|
|
||||||
| Formatter | Representation |
|
|
||||||
|-----------|----------------|
|
|
||||||
| `%O` | Pretty-print an Object on multiple lines. |
|
|
||||||
| `%o` | Pretty-print an Object all on a single line. |
|
|
||||||
| `%s` | String. |
|
|
||||||
| `%d` | Number (both integer and float). |
|
|
||||||
| `%j` | JSON. Replaced with the string '[Circular]' if the argument contains circular references. |
|
|
||||||
| `%%` | Single percent sign ('%'). This does not consume an argument. |
|
|
||||||
|
|
||||||
|
|
||||||
### Custom formatters
|
|
||||||
|
|
||||||
You can add custom formatters by extending the `debug.formatters` object.
|
|
||||||
For example, if you wanted to add support for rendering a Buffer as hex with
|
|
||||||
`%h`, you could do something like:
|
|
||||||
|
|
||||||
```js
|
|
||||||
const createDebug = require('debug')
|
|
||||||
createDebug.formatters.h = (v) => {
|
|
||||||
return v.toString('hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
// …elsewhere
|
|
||||||
const debug = createDebug('foo')
|
|
||||||
debug('this is hex: %h', new Buffer('hello world'))
|
|
||||||
// foo this is hex: 68656c6c6f20776f726c6421 +0ms
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## Browser Support
|
|
||||||
|
|
||||||
You can build a browser-ready script using [browserify](https://github.com/substack/node-browserify),
|
|
||||||
or just use the [browserify-as-a-service](https://wzrd.in/) [build](https://wzrd.in/standalone/debug@latest),
|
|
||||||
if you don't want to build it yourself.
|
|
||||||
|
|
||||||
Debug's enable state is currently persisted by `localStorage`.
|
|
||||||
Consider the situation shown below where you have `worker:a` and `worker:b`,
|
|
||||||
and wish to debug both. You can enable this using `localStorage.debug`:
|
|
||||||
|
|
||||||
```js
|
|
||||||
localStorage.debug = 'worker:*'
|
|
||||||
```
|
|
||||||
|
|
||||||
And then refresh the page.
|
|
||||||
|
|
||||||
```js
|
|
||||||
a = debug('worker:a');
|
|
||||||
b = debug('worker:b');
|
|
||||||
|
|
||||||
setInterval(function(){
|
|
||||||
a('doing some work');
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
setInterval(function(){
|
|
||||||
b('doing some work');
|
|
||||||
}, 1200);
|
|
||||||
```
|
|
||||||
|
|
||||||
In Chromium-based web browsers (e.g. Brave, Chrome, and Electron), the JavaScript console will—by default—only show messages logged by `debug` if the "Verbose" log level is _enabled_.
|
|
||||||
|
|
||||||
<img width="647" src="https://user-images.githubusercontent.com/7143133/152083257-29034707-c42c-4959-8add-3cee850e6fcf.png">
|
|
||||||
|
|
||||||
## Output streams
|
|
||||||
|
|
||||||
By default `debug` will log to stderr, however this can be configured per-namespace by overriding the `log` method:
|
|
||||||
|
|
||||||
Example [_stdout.js_](./examples/node/stdout.js):
|
|
||||||
|
|
||||||
```js
|
|
||||||
var debug = require('debug');
|
|
||||||
var error = debug('app:error');
|
|
||||||
|
|
||||||
// by default stderr is used
|
|
||||||
error('goes to stderr!');
|
|
||||||
|
|
||||||
var log = debug('app:log');
|
|
||||||
// set this namespace to log via console.log
|
|
||||||
log.log = console.log.bind(console); // don't forget to bind to console!
|
|
||||||
log('goes to stdout');
|
|
||||||
error('still goes to stderr!');
|
|
||||||
|
|
||||||
// set all output to go via console.info
|
|
||||||
// overrides all per-namespace log settings
|
|
||||||
debug.log = console.info.bind(console);
|
|
||||||
error('now goes to stdout via console.info');
|
|
||||||
log('still goes to stdout, but via console.info now');
|
|
||||||
```
|
|
||||||
|
|
||||||
## Extend
|
|
||||||
You can simply extend debugger
|
|
||||||
```js
|
|
||||||
const log = require('debug')('auth');
|
|
||||||
|
|
||||||
//creates new debug instance with extended namespace
|
|
||||||
const logSign = log.extend('sign');
|
|
||||||
const logLogin = log.extend('login');
|
|
||||||
|
|
||||||
log('hello'); // auth hello
|
|
||||||
logSign('hello'); //auth:sign hello
|
|
||||||
logLogin('hello'); //auth:login hello
|
|
||||||
```
|
|
||||||
|
|
||||||
## Set dynamically
|
|
||||||
|
|
||||||
You can also enable debug dynamically by calling the `enable()` method :
|
|
||||||
|
|
||||||
```js
|
|
||||||
let debug = require('debug');
|
|
||||||
|
|
||||||
console.log(1, debug.enabled('test'));
|
|
||||||
|
|
||||||
debug.enable('test');
|
|
||||||
console.log(2, debug.enabled('test'));
|
|
||||||
|
|
||||||
debug.disable();
|
|
||||||
console.log(3, debug.enabled('test'));
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
print :
|
|
||||||
```
|
|
||||||
1 false
|
|
||||||
2 true
|
|
||||||
3 false
|
|
||||||
```
|
|
||||||
|
|
||||||
Usage :
|
|
||||||
`enable(namespaces)`
|
|
||||||
`namespaces` can include modes separated by a colon and wildcards.
|
|
||||||
|
|
||||||
Note that calling `enable()` completely overrides previously set DEBUG variable :
|
|
||||||
|
|
||||||
```
|
|
||||||
$ DEBUG=foo node -e 'var dbg = require("debug"); dbg.enable("bar"); console.log(dbg.enabled("foo"))'
|
|
||||||
=> false
|
|
||||||
```
|
|
||||||
|
|
||||||
`disable()`
|
|
||||||
|
|
||||||
Will disable all namespaces. The functions returns the namespaces currently
|
|
||||||
enabled (and skipped). This can be useful if you want to disable debugging
|
|
||||||
temporarily without knowing what was enabled to begin with.
|
|
||||||
|
|
||||||
For example:
|
|
||||||
|
|
||||||
```js
|
|
||||||
let debug = require('debug');
|
|
||||||
debug.enable('foo:*,-foo:bar');
|
|
||||||
let namespaces = debug.disable();
|
|
||||||
debug.enable(namespaces);
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: There is no guarantee that the string will be identical to the initial
|
|
||||||
enable string, but semantically they will be identical.
|
|
||||||
|
|
||||||
## Checking whether a debug target is enabled
|
|
||||||
|
|
||||||
After you've created a debug instance, you can determine whether or not it is
|
|
||||||
enabled by checking the `enabled` property:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const debug = require('debug')('http');
|
|
||||||
|
|
||||||
if (debug.enabled) {
|
|
||||||
// do stuff...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also manually toggle this property to force the debug instance to be
|
|
||||||
enabled or disabled.
|
|
||||||
|
|
||||||
## Usage in child processes
|
|
||||||
|
|
||||||
Due to the way `debug` detects if the output is a TTY or not, colors are not shown in child processes when `stderr` is piped. A solution is to pass the `DEBUG_COLORS=1` environment variable to the child process.
|
|
||||||
For example:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
worker = fork(WORKER_WRAP_PATH, [workerPath], {
|
|
||||||
stdio: [
|
|
||||||
/* stdin: */ 0,
|
|
||||||
/* stdout: */ 'pipe',
|
|
||||||
/* stderr: */ 'pipe',
|
|
||||||
'ipc',
|
|
||||||
],
|
|
||||||
env: Object.assign({}, process.env, {
|
|
||||||
DEBUG_COLORS: 1 // without this settings, colors won't be shown
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
worker.stderr.pipe(process.stderr, { end: false });
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## Authors
|
|
||||||
|
|
||||||
- TJ Holowaychuk
|
|
||||||
- Nathan Rajlich
|
|
||||||
- Andrew Rhyne
|
|
||||||
- Josh Junon
|
|
||||||
|
|
||||||
## Backers
|
|
||||||
|
|
||||||
Support us with a monthly donation and help us continue our activities. [[Become a backer](https://opencollective.com/debug#backer)]
|
|
||||||
|
|
||||||
<a href="https://opencollective.com/debug/backer/0/website" target="_blank"><img src="https://opencollective.com/debug/backer/0/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/1/website" target="_blank"><img src="https://opencollective.com/debug/backer/1/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/2/website" target="_blank"><img src="https://opencollective.com/debug/backer/2/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/3/website" target="_blank"><img src="https://opencollective.com/debug/backer/3/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/4/website" target="_blank"><img src="https://opencollective.com/debug/backer/4/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/5/website" target="_blank"><img src="https://opencollective.com/debug/backer/5/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/6/website" target="_blank"><img src="https://opencollective.com/debug/backer/6/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/7/website" target="_blank"><img src="https://opencollective.com/debug/backer/7/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/8/website" target="_blank"><img src="https://opencollective.com/debug/backer/8/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/9/website" target="_blank"><img src="https://opencollective.com/debug/backer/9/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/10/website" target="_blank"><img src="https://opencollective.com/debug/backer/10/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/11/website" target="_blank"><img src="https://opencollective.com/debug/backer/11/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/12/website" target="_blank"><img src="https://opencollective.com/debug/backer/12/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/13/website" target="_blank"><img src="https://opencollective.com/debug/backer/13/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/14/website" target="_blank"><img src="https://opencollective.com/debug/backer/14/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/15/website" target="_blank"><img src="https://opencollective.com/debug/backer/15/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/16/website" target="_blank"><img src="https://opencollective.com/debug/backer/16/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/17/website" target="_blank"><img src="https://opencollective.com/debug/backer/17/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/18/website" target="_blank"><img src="https://opencollective.com/debug/backer/18/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/19/website" target="_blank"><img src="https://opencollective.com/debug/backer/19/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/20/website" target="_blank"><img src="https://opencollective.com/debug/backer/20/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/21/website" target="_blank"><img src="https://opencollective.com/debug/backer/21/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/22/website" target="_blank"><img src="https://opencollective.com/debug/backer/22/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/23/website" target="_blank"><img src="https://opencollective.com/debug/backer/23/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/24/website" target="_blank"><img src="https://opencollective.com/debug/backer/24/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/25/website" target="_blank"><img src="https://opencollective.com/debug/backer/25/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/26/website" target="_blank"><img src="https://opencollective.com/debug/backer/26/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/27/website" target="_blank"><img src="https://opencollective.com/debug/backer/27/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/28/website" target="_blank"><img src="https://opencollective.com/debug/backer/28/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/backer/29/website" target="_blank"><img src="https://opencollective.com/debug/backer/29/avatar.svg"></a>
|
|
||||||
|
|
||||||
|
|
||||||
## Sponsors
|
|
||||||
|
|
||||||
Become a sponsor and get your logo on our README on Github with a link to your site. [[Become a sponsor](https://opencollective.com/debug#sponsor)]
|
|
||||||
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/0/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/0/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/1/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/1/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/2/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/2/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/3/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/3/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/4/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/4/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/5/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/5/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/6/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/6/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/7/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/7/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/8/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/8/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/9/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/9/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/10/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/10/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/11/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/11/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/12/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/12/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/13/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/13/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/14/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/14/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/15/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/15/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/16/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/16/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/17/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/17/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/18/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/18/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/19/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/19/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/20/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/20/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/21/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/21/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/22/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/22/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/23/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/23/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/24/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/24/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/25/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/25/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/26/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/26/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/27/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/27/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/28/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/28/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/debug/sponsor/29/website" target="_blank"><img src="https://opencollective.com/debug/sponsor/29/avatar.svg"></a>
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
(The MIT License)
|
|
||||||
|
|
||||||
Copyright (c) 2014-2017 TJ Holowaychuk <tj@vision-media.ca>
|
|
||||||
Copyright (c) 2018-2021 Josh Junon
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of this software and associated documentation files (the
|
|
||||||
'Software'), to deal in the Software without restriction, including
|
|
||||||
without limitation the rights to use, copy, modify, merge, publish,
|
|
||||||
distribute, sublicense, and/or sell copies of the Software, and to
|
|
||||||
permit persons to whom the Software is furnished to do so, subject to
|
|
||||||
the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be
|
|
||||||
included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
||||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
||||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
||||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
||||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue