Route Forge — 功能规格说明书

对外功能承诺,所有实现都应对照此文档验证。

1. 产品概述

Route Forge 是 Laravel 命名路由的全链路解决方案,由两个独立仓库组成:

两侧通过 HTTP manifest 契约交互,版本独立演进。

2. 目标用户

画像 典型场景
Laravel 全栈开发者 Laravel + Vue/React SPA 项目,想让前端调用 API 更优雅
大型 SPA 项目维护者 接口数 > 100,首屏性能敏感
多角色系统开发者 有 admin/user/guest 等多种权限级别
TypeScript 重度用户 要求前后端全链路类型安全

3. 后端功能(route-forge/laravel

3.1 核心能力

Route Forge 提供三种互相兼容的路由层级分配方式,任选其一或组合使用。三层级名(如 admin/manage/clientpublic/user/admin )完全由项目自定义,包不预设固定层级(见 DESIGN.md §6.4)。

3.1.1 定义路由时显式标记(->tier() 宏)

通过宏 tier() 在定义路由时显式标记所属层级,链式调用对资源路由同样生效:

Route::post('/auth/login', [AuthController::class, 'login'])
    ->name('auth.login')
    ->tier('public');

Route::resource('users', UserController::class)
    ->tier('manage');

宏通过 ServiceProvider 注册到 Illuminate\Routing\Route,仅向 action 数组写入一个 tier 字段,零侵入:

Route::macro('tier', function (string $tier) {
    $this->action['tier'] = $tier;
    return $this;
});

3.1.2 配置文件按规则批量分配

config/forge.php 中按 match 规则把路由批量匹配到对应层级,无需逐条标注:

// config/forge.php
return [
    'levels' => [
        'public' => [
            'description' => '公共接口(无需登录)',
            'match' => [
                'prefix'     => ['auth', 'public'],
                'middleware' => [],
            ],
            'load'  => 'eager',
            'cache' => 3600,
        ],
        'client' => [
            'description' => '客户端用户接口',
            'match' => [
                'prefix'     => ['client'],
                'middleware' => ['auth'],
            ],
            'load'  => 'lazy',
        ],
        'manage' => [
            'description' => '运营管理接口',
            'match' => [
                'prefix'     => ['manage'],
                'middleware' => ['auth', 'manage'],
            ],
            'load'  => 'lazy',
        ],
        'admin' => [
            'description' => '系统管理接口',
            'match' => [
                'prefix'     => ['admin'],
                'middleware' => ['auth', 'admin'],
                'middleware_match' => 'all',  // 要求同时包含 auth 和 admin
            ],
            'load'  => 'lazy',
        ],
    ],

    'endpoint_prefix' => '/_forge/routes',  // 路由元信息对外端点前缀
    'url_prefix'      => '',                // URL 前缀,下发给前端拼接路由 URL(如 '/api/v1')
    'cache_driver'    => null,             // null=使用默认缓存驱动
    'strict_mode'     => false,            // 严格模式:未命中层级即抛异常
    'fallback_level'  => null,             // null=未命中路由归入「未分配」分组;非 null 则归入指定层级
    'classifier'      => null,             // 自定义分类回调,签名 fn(Route $r): ?string
];

匹配规则:

中间件匹配模式(middleware_match

每个层级可在 match 中配置 middleware_match,控制 middleware 数组的匹配逻辑。不配置时默认 'any'

简单模式:

含义
'any'(默认) 路由中间件集合包含 middleware 数组中任意一项即命中(OR 逻辑)
'all' 路由中间件集合包含 middleware 数组中全部项才命中(AND 逻辑)

高级模式(数组 DNF 结构):

middleware 数组索引(从 0 开始)为操作数,用嵌套数组表达布尔逻辑——内层数组为 AND,外层数组为 OR(析取范式 / DNF):

配置示例 等价逻辑
[[0, 1]] middleware[0] AND middleware[1]
[[0], [1]] middleware[0] OR middleware[1]
[[0, 1], [2]] (middleware[0] AND middleware[1]) OR middleware[2]
[[0], [1, 2]] middleware[0] OR (middleware[1] AND middleware[2])
// 示例 1:admin 层级要求同时有 auth 和 admin 中间件(AND)
'admin' => [
    'match' => [
        'prefix'     => ['admin'],
        'middleware' => ['auth', 'admin'],
        'middleware_match' => 'all',
    ],
    'load' => 'lazy',
],

// 示例 2:复杂条件 (有 auth 且有 admin) 或 (有 super_admin)
'admin' => [
    'match' => [
        'prefix'     => ['admin'],
        'middleware' => ['auth', 'admin', 'super_admin'],
        'middleware_match' => [[0, 1], [2]],
    ],
    'load' => 'lazy',
],

选择 DNF 数组而非字符串表达式的原因:PHP 原生数组无需额外解析器,写错时 PHP 直接报类型错误,不会静默失败。DNF 可表达所有布尔组合,覆盖实际使用场景。

3.1.3 路由分组分配层级(Route::group 透传)

Route::group 上支持 tier 选项,整组路由继承层级,避免重复标注。命名空间、中间件、前缀等 Laravel 原生 group 选项继续正常工作:

Route::group([
    'prefix'     => 'admin',
    'middleware' => ['auth', 'admin'],
    'tier'       => 'admin',   // ← 新增选项
], function () {
    Route::get('/users', [AdminUserController::class, 'index'])
         ->name('admin.users.index');
    Route::post('/users', [AdminUserController::class, 'store'])
         ->name('admin.users.store');
    // 组内所有路由自动归属 admin 层级
});

嵌套 group 行为(与 Laravel 中 middleware 合并策略一致):

Route::group(['tier' => 'admin'], function () {
    Route::get('/a', ...);              // admin

    Route::group(['tier' => 'manage'], function () {
        Route::get('/b', ...);          // manage(内层覆盖外层)
    });
});

实现方式:包在 boot 阶段监听 Route::group 调用,把 tier 透传到组内每条路由的 action,等价于自动给组内每条路由调用 ->tier()。分组标记与单条显式 ->tier() 相比,单条优先级更高。

3.1.4 层级分配优先级

当多种分配方式并存时,按以下优先级决定一条路由的最终层级(高优先级覆盖低优先级):

  1. 显式 ->tier() 调用(最高)
  2. Route::group tier 选项(继承自最近一层 group,内层覆盖外层)
  3. classifier 自定义回调返回非 null 值
  4. 配置文件 match 规则匹配(受 middleware_match 控制)
  5. 兜底fallback_level 非 null 时归入指定层级;fallback_level=null 时归入「未分配」分组(可通过摘要端点 §3.1.6 获取)

优先级设计意图:显式标注胜过隐式分组,分组胜过全局规则,全局规则胜过兜底。fallback_level=null 的设计允许项目不做兜底分配——未标记路由仍可被前端调用,只是需要通过摘要端点的 unassigned 字段发现。

⚠️ 安全考量:fallback_level=null 时未标记路由会通过摘要端点暴露路由名和 URI 模板。生产环境建议配合 strict_mode=true 或显式标记所有路由,避免信息泄露。

3.1.5 层级元信息查询端点

包在 endpoint_prefix(默认 /_forge/routes)下注册端点,按层级返回路由元信息(名称 + URI + method + 参数定义),供前端按需懒加载:

GET /_forge/routes/{level}   # 返回该层级下所有命名路由的元信息

返回示例:

{
  "level": "admin",
  "routes": {
    "admin.users.index": {
      "uri": "admin/users",
      "methods": [
        "GET",
        "HEAD"
      ],
      "parameters": [],
      "parameter_defaults": {}
    },
    "admin.users.show": {
      "uri": "admin/users/{user}",
      "methods": [
        "GET",
        "HEAD"
      ],
      "parameters": [
        "user"
      ],
      "parameter_defaults": {}
    },
    "admin.posts.index": {
      "uri": "admin/posts/{page}",
      "methods": [
        "GET",
        "HEAD"
      ],
      "parameters": [
        "page"
      ],
      "parameter_defaults": {
        "page": 1
      }
    }
  }
}

字段说明:

缓存:响应按层级独立缓存(cache 配置项控制 TTL,单位秒,null 不缓存,0 永久缓存)。

⚠️ cache: 0 遵循 Laravel Cache TTL 惯例(永久缓存),非 HTTP Cache-Control: max-age=0 含义。Route Forge 缓存仅通过包内部管理(Cache facade / 配置的 cache_driver),不使用 HTTP 响应头。

3.1.6 层级摘要端点

包同时注册一个摘要端点,返回所有层级的概览信息与后端全局配置,供前端初始化时自动发现层级、读取后端配置:

GET /_forge/routes   # 返回所有层级摘要 + 全局配置

返回示例:

{
  "schemeVersion": 1,
  "levels": {
    "public": {
      "description": "公共接口(无需登录)",
      "load": "eager",
      "route_count": 12,
      "route": { "uri": "/_forge/routes/public", "methods": ["GET", "HEAD"] }
    },
    "client": {
      "description": "客户端用户接口",
      "load": "lazy",
      "route_count": 45,
      "route": { "uri": "/_forge/routes/client", "methods": ["GET", "HEAD"] }
    },
    "admin": {
      "description": "系统管理接口",
      "load": "lazy",
      "route_count": 27,
      "route": { "uri": "/_forge/routes/admin", "methods": ["GET", "HEAD"] }
    },
    "unassigned": {
      "description": "未命中任何层级的路由",
      "load": "lazy",
      "route_count": 3,
      "route": { "uri": "/_forge/routes/unassigned", "methods": ["GET", "HEAD"] }
    }
  },
  "config": {
    "strict_mode": false,
    "endpoint_prefix": "/_forge/routes",
    "url_prefix": null,
    "cache_ttl": 3600
  }
}

字段说明:

unassigned:未命中任何层级的命名路由归属层级。后端恒在 levels 中注入该特殊层级route.uri = {endpoint_prefix}/unassigned), 前端将其与已定义层级一视同仁,按 route.uri 走 HTTP 懒加载获取明细——不再有"顶层 unassigned 数组 + 前端虚拟层级就地构建"strict_mode=true 时后端对未命中路由直接抛错,levels.unassigned.route_count 为 0。

摘要与层级明细的获取来源级联见 §4.1.1 / §3.1.8:页面内嵌(window.__ROUTE_FORGE__)> createRouteForge({ summary }) > 网络拉取 endpoint。三者投递的是同一份 SummaryResponse

3.1.8 摘要的页面内嵌投递(hydration)

针对 Laravel/Blade 服务端直出的首页,后端提供 @forgeSummary 指令,把与摘要端点逐字段一致SummaryResponse 内联进 HTML <head>,让前端 core 初始化时直接消费、跳过首屏的一次摘要 HTTP 往返。

3.2 Artisan命令

php artisan route:forge:list

查看所有路由的层级分配结果,用于开发调试和验证配置是否正确:

# 查看所有路由的层级分配
php artisan route:forge:list
# 仅查看指定层级
php artisan route:forge:list --level=admin
# JSON 格式输出(便于脚本处理)
php artisan route:forge:list --json
# 仅显示未分配层级的路由
php artisan route:forge:list --unassigned

输出示例:

Name Level Methods URI
auth.login public POST auth/login
admin.users.index admin GET|HEAD admin/users
admin.users.show admin GET| HEAD admin/users/{user}
client.orders.store client POST client/orders
debug.info ⚠ 未分配 GET| HEAD _debug/info

行为说明:

设计意图:开发阶段最常被问到的问题是"我的路由到底被分到了哪个层级"。这个命令让开发者无需启动前端、无需打开浏览器,一条命令即可验证配置效果。

php artisan route:forge:types

从 Laravel 路由注册表生成 TS 类型声明文件,为前端 forge.api(level, name, params) 调用提供编译期类型安全:

# 生成所有层级的路由类型(默认输出到 stdout,便于预览和管道处理)
php artisan route:forge:types
# 仅生成指定层级
php artisan route:forge:types --level=admin
# 写入文件(跨项目写入前端目录)
php artisan route:forge:types --out=../frontend/src/types/forge-routes.d.ts
# JSON 格式输出(便于脚本或工具链二次消费)
php artisan route:forge:types --json

生成结果示例(d.ts):

declare const routes: {
    'admin.users.show': {
        method: 'GET'; params: { user: string | number };
        // 响应类型默认 unknown,可通过业务侧响应类型映射文件补充
        response: unknown;
    };
    'manage.users.store': {
        method: 'POST';
        params: {};
        body: unknown;
        response: unknown;
    };
};

行为说明:

设计意图:路由定义是后端数据,类型必须由后端这个唯一真相源生成——后端改了路由,跑一次命令前端类型即同步,杜绝手写类型与路由表脱节。相比前端 CLI 请求端点生成,Artisan 命令离线可用,CI 中 PHP 构建阶段无需 Node 环境。

4. 前端功能(@route-forge/core + @route-forge/vue)

4.1 核心能力

@route-forge/core 是框架无关的命名路由客户端核心,提供按层级懒加载、隔离缓存、并发去重三项基础能力。 @route-forge/vue 在其上提供 Vue 3 插件与 composable。

4.1.1 客户端初始化

import {createRouteForge} from '@route-forge/core';

const forge = createRouteForge({
    endpoint: '/_forge/routes',   // 后端元信息端点前缀,与 forge.endpoint_prefix 对齐
    levels: ['public', 'client', 'manage', 'admin'],  // 与后端 levels 键对齐
    eager: ['public'],             // 初始化时立即拉取的层级
    adapter: 'auto',             // 'auto'(默认)| 'axios' | 'builtin' | 自定义 Fetcher
    // 'auto' 解析顺序:
    //   1. 检测到宿主项目装了 axios → 用 axios(自动复用其拦截器配置)
    //   2. 否则 → 使用包内置的类 axios 精简实现(见 4.3.1)
    // 显式传 'axios' 强制使用宿主 axios(未安装则抛 AdapterNotFoundError)
    // 显式传 'builtin' 强制使用内置实现(即使装了 axios 也不用)
    // 传自定义 Fetcher 接口(见 4.3.3)跳过 auto 检测
    cache: {
        ttl: 3600,                  // 前端兜底缓存 TTL(秒);TTL 唯一来源为摘要 config.cache_ttl(全局),此值仅在后端未下发时兜底,且只能缩短不能延长
        storage: 'memory',          // 'memory' | 'sessionStorage' | 'localStorage'
    },
    interceptors: {              // 声明式注册(等价于创建后调用一次 forge.interceptors.*.use),可选
        // 只描述「一个」拦截器,三种写法:
        //   函数 resolve          → onFulfilled
        //   [resolve?, reject?]   → 成功 / 失败(允许缺位,如 [undefined, reject] 仅失败)
        //   { resolve?, reject? } → 具名成功 / 失败
        // 需要注册多个拦截器请改用运行时 forge.interceptors.request / response.use()(可多次调用)
        request: (config) => {
            config.headers.Authorization = `Bearer ${token()}`;
            return config;
        },
        response: {
            resolve: (res) => res.data?.data ?? res.data,   // onFulfilled
            reject: (err) => handle401(err),                // onRejected
        },
    },
});

// 运行时动态注册(与 axios 一致):use() 返回 id,eject(id) 移除
// 可多次调用 use() 注册多个拦截器,内部以数组保存,按注册顺序执行
const idAuth = forge.interceptors.request.use(
    (config) => {
        config.headers.Authorization = `Bearer ${token()}`;
        return config;
    },
);
const idLog = forge.interceptors.request.use(
    (config) => {
        console.debug('[forge]', config.route, config.method);
        return config;
    },
);
// 执行顺序(axios 惯例):idLog → idAuth(请求拦截器后注册先执行,LIFO)

// 移除时通过 eject(id) 移除指定拦截器,互不影响
forge.interceptors.request.eject(idLog);   // 仅移除日志拦截器,鉴权拦截器保留

// 一键清空所有拦截器(如登出时重置)
forge.interceptors.request.clear();

设计要点:

4.1.2 按层级懒加载与隔离缓存

// 显式拉取某层级(已加载则直接返回缓存,未加载则发起请求)
await forge.load('admin');

// 多层级并发拉取(内部自动去重,详见 4.1.4)
await forge.load(['client', 'manage']);

缓存规则:

4.1.3 通过层级 + 路由名调用 API(核心 API)

前端不需要关心 URL 和 HTTP 方法,只需层级 + 路由名 + 参数:

// 等价于 GET /admin/users/123
const user = await forge.api('admin', 'users.show', {user: 123});

// forge.url() 是 forge.route() 的语义别名,适用于链接生成等场景
const profileUrl = forge.url('admin', 'users.show', {user: 123});

// 等价于 POST /manage/users + JSON body
const created = await forge.api('manage', 'users.store', {body: {name: 'Alice'}});

// 路由参数 + query + body 同时存在
await forge.api('client', 'posts.update', {
    post: 456,           // 路径参数:填充到 posts/{post}
    query: {silent: true},  // 查询参数
    body: {title: 'new'},    // 请求体
});
参数智能解析

forge.api() 的第三参数支持四种数据类型:路径参数、查询参数(query)、请求体(body)、请求头(headers )。其中 query/body/headers 为固定 key,其余平铺 key 均视为路径参数。同时提供 params 固定 key 用于显式指定路径参数。

当路径参数名与 query/body/headers 冲突时(如路由为 /search/{query}),按以下规则智能消解:

场景 处理方式
params 显式指定 路径参数,优先级最高
平铺 key 值为 string | number 智能识别为路径参数(含与 query/body/headers 同名)
query 值为对象 查询参数(原定义)
body 值为非 string/number 请求体(原定义)
headers 值为对象 请求头(原定义)
// 向后兼容:原有写法不变
forge.api('admin', 'users.show', { user: 1, query: { include: 'posts' } })

// 冲突消解:query 为 string → 自动识别为路径参数
// 路由: /search/{query}
forge.api('admin', 'search.show', { query: 'keyword' })
// → URL: /search/keyword(无 query string)

// 冲突消解:query 为对象 → 按原定义作为查询参数
forge.api('admin', 'search.show', { query: { keyword: 'test' } })
// → query string: ?keyword=test(路径参数 {query} 缺失,抛 MissingRouteParamError)

// 显式 params:同时需要路径参数和 query string
forge.api('admin', 'search.show', {
  params: { query: 'keyword' },   // → 替换 {query}(params 优先)
  query: { page: 1 },             // → query string(按原定义)
  body: { detailed: true },       // → 请求体(按原定义)
})
// → URL: /search/keyword?page=1, body: { detailed: true }

// params 也用于路径参数名与固定 key 冲突时的显式指定
// 路由: /items/{body}
forge.api('admin', 'items.show', {
  params: { body: 'special-id' },  // → 替换 {body}
  body: { filter: 'active' },      // → 请求体(按原定义)
})
请求取消(ForgeRequest.abort)

forge.api() 返回 ForgeRequest(继承 Promise),内置 abort() 方法用于取消请求。内部自动创建 AbortController,用户无需手动管理:

// 发起请求
const request = forge.api('admin', 'users.index');

// 取消请求
request.abort();

// request 将 reject 为 RequestAbortedError (code: RF_FE_009)
try {
  await request;
} catch (e) {
  if (e instanceof RequestAbortedError) {
    console.log('请求已取消');
  }
}

取消行为说明:

调用流程:

设计要点:路由校验前置到拦截链之前,错误恢复从下一个拦截器继续

  1. 按层级 + 路由名查本地缓存;若该层级尚未加载,自动 forge.load(level) 等待完成(隐式懒加载)。
  2. 路由校验(始终执行,独立于拦截链,不受拦截器影响):
  1. 从路由元信息读取 uri 和 methods,取第一个非 HEAD 的方法作为请求方法。
  2. 用传入的路径参数填充 URI 模板({user} → 123),剩余参数不允许填充路径。
  3. 拼 query、序列化 body,构建 RequestConfig(详见 4.1.3a)。
  4. 请求拦截链:按 axios 惯例(LIFO,后注册先执行)依次执行 forge.interceptors.request 中已注册的 onFulfilled,每段接收上一段返回的 RequestConfig 并可修改后返回;任一段抛错则跳到请求拦截的 onRejected,仍抛错则进入调用方 catch,不再发请求。
  5. 调用 adapter 发请求。
  6. 响应拦截链:HTTP 2xx 时,按 axios 惯例(FIFO,先注册先执行)依次执行 forge.interceptors.response 的 onFulfilled,每段接收上一段返回值(首段接收 ResponseData,详见 4.1.3a)并返回新值;末段返回值即为 forge.api() 的 resolve 值。
  7. 错误拦截链:HTTP 非 2xx 或任一 onFulfilled 抛错时,按 FIFO(与响应 onFulfilled 同序)依次执行 forge.interceptors.response 的 onRejected;任一段 reject 或全部跳过则进入调用方 catch;某段返回值则恢复为正常流程,从下一个拦截器的 onFulfilled 继续执行。请求拦截 onRejected 链同样按 LIFO(与请求 onFulfilled 同序)。
graph TD
    A["forge.api(level, name, params)"] --> B{"路由校验(步骤 2)"}
    B -- " 校验失败 " --> C["抛出 ForgeError(不发请求)"]
    B -- " 校验通过 " --> D["构建 RequestConfig(步骤 3-5)"]
    D --> E["请求拦截链 onFulfilled(LIFO,后注册先执行)"]
    E -- " 某段抛错 " --> F["请求拦截链 onRejected(LIFO,与 onFulfilled 同序)"]
    F -- " 某段返回值(恢复) " --> E
    F -- " 全部 reject " --> G["进入调用方 catch(不发请求)"]
    E -- " 全部通过 " --> H["调用 adapter 发请求(步骤 7)"]
    H -- " HTTP 2xx " --> I["响应拦截链 onFulfilled(FIFO,先注册先执行)"]
    H -- " HTTP 非 2xx " --> J["响应拦截链 onRejected(FIFO,与 onFulfilled 同序)"]
    H -- " 网络错误 " --> J
    I -- " 某段抛错 " --> K["响应拦截链 onRejected(从当前拦截器位置开始,FIFO)"]
    K -- " 某段返回值(恢复) " --> L["从下一个拦截器的 onFulfilled 继续"]
    K -- " 全部 reject " --> G
    J -- " 某段返回值(恢复) " --> L
    J -- " 全部 reject " --> G
    L -- " 后续全部通过 " --> M["末段返回值 = forge.api() resolve 值"]
    I -- " 全部通过 " --> M

4.1.3a 拦截器签名

// 请求拦截器接收的配置对象(可变,返回修改后的版本)
type RouteMeta = {
    uri: string;
    methods: string[];
    parameters: string[];
  parameter_defaults?: Record<string, unknown>;  // 路径参数默认值(Laravel ->defaults())
};
type RequestConfig = {
    route: string;            // 路由名,如 'admin.users.show'
    level: string;            // 层级,如 'admin'
    method: string;           // HTTP 方法,如 'GET'
    url: string;              // 完整 URL(含 query string)
    headers: Record<string, string>;
    body?: unknown;           // 请求体(已序列化前)
    params: Record<string, unknown>;  // 已填入路径的参数
    meta: RouteMeta;          // 路由元信息(uri/methods/parameters 等)
  timeout?: number;         // 单次请求超时覆盖(毫秒)
  signal?: AbortSignal;     // 请求取消信号(内部自动创建,拦截器可修改)
  paramsSerializer?: (params: Record<string, unknown>) => string;  // 自定义 query 序列化(builtin adapter)
};

// 响应拦截器接收的数据对象(首段 onFulfilled 接收完整 ResponseData,后续段接收上一段返回值)
type ResponseData = {
    route: string;
    level: string;
    method: string;
    url: string;
    status: number;           // HTTP 状态码
    headers: Headers;         // 响应头
    data: unknown;            // 响应体(adapter 已按 Content-Type 解析)
    config: RequestConfig;    // 触发本次请求的配置(请求拦截链输出)
};

// 拦截器函数:支持同步返回或 async/Promise
type RequestInterceptor = (config: RequestConfig) => RequestConfig | Promise<RequestConfig>;
type ResponseInterceptor = (response: ResponseData) => unknown | Promise<unknown>;
type ErrorHandler = (error: unknown) => unknown | Promise<unknown>;

// 注册 API(与 axios 一致):use() 返回 id,eject(id) 移除
interface InterceptorManager<T> {
    use(onFulfilled?: (value: T) => T | Promise<T>,
        onRejected?: (error: unknown) => unknown | Promise<unknown>): number;

    eject(id: number): void;

    clear(): void;
}

forge.interceptors.request:InterceptorManager<RequestConfig>;
forge.interceptors.response:InterceptorManager<ResponseData>;

设计约定:

4.1.4 并发控制与去重

同层级并发请求自动合并为一次:

// 首屏 10 个组件同时调用 forge.api('admin', 'xxx', ...),但 admin 层级尚未加载
// 内部只发起 1 次 GET /_forge/routes/admin,10 个调用共用加载完成的 Promise
const [a, b, c] = await Promise.all([
    forge.api('admin', 'users.index'),
    forge.api('admin', 'users.show', {user: 1}),
    forge.api('admin', 'roles.list'),
]);

去重规则:

批量预加载接口提供 Promise.all 友好的入口:

await forge.load(['client', 'manage', 'admin']);  // 并发去重 + 并发请求

4.1.5 严格模式

前端校验始终开启,不存在 strict 开关。 前端的 strict 配置项已废弃(v1.3.2 起不再消费), 校验行为与 strict 无关:

场景 行为
必填路径参数缺失(无默认值) MissingRouteParamError(始终校验)
必填路径参数缺失(有默认值) 用默认值填充,不抛错(始终填充)
路由名不存在 UnknownRouteError(始终抛出)
路由所在层级未声明 UnknownLevelError(始终抛出)

设计意图:静默忽略未声明层级会掩盖层级名拼写错误,调用方不知道哪里出错、难以排查,因此前端校验永远开启。 strict_mode后端语义:决定后端在生成 manifest 时,未命中层级的路由是抛异常(strict_mode=true) 还是归入 fallback/unassigned(strict_mode=false,见 §3.1)。前端拿到的 manifest 已经是后端 strict_mode 处理后的结果,无需(也无法)在前端放宽或收紧。

4.1.6 就绪等待与层级绑定(ready() / use()

forge.ready() 方法

forge.ready() 在 auto-discovery + eager load 全部完成后 resolve,始终返回 Promise<RouteForge>(resolve 值为 forge 实例自身),支持两种调用模式:

// 无参模式:返回 Promise,适合 async/await
const forge = createRouteForge({ /* ... */ });
await forge.ready();
// 此时 route() / hasRoute() 可安全使用

// 回调模式:onFulfilled / onRejected 内部走 then/catch,仍返回 Promise
forge.ready(
  (f) => { console.log('ready!', f); },
  (err) => { console.error('forge init failed', err); }
);
forge.isReady() 方法

forge.isReady(): boolean 同步查询 ready() 是否已成功 resolve(auto-discovery + eager 完成); reject 不算就绪(保持 false)。只读、不触发任何加载。供框架层 ready 门闩同步判定、避免多余的 fallback 首帧(vue ForgeReady / react <RouteForgeProvider gate>)。

框架层 ready 门闩(vue / react)

「整应用等待就绪」有两种同等支持的方式,效果等价(ready 前不渲染路由依赖内容):

forge.use(level?, prefix?) 方法

forge.use() 是 core 层唯一的 level 绑定入口,Vue/React/IIFE 三端共享同一套 API 表面:

// 不传 level:返回 RouteForge 自身(等价于无操作)
const f = forge.use();

// 传 level:返回 BoundForge,自动触发 load
const bound = forge.use('admin');
bound('users.show', { user: 1 });   // 可直接调用(= bound.api())
bound.route('users.show');           // URL 生成

// 传 level + prefix:返回 BoundForge,路由名自动拼接前缀
const bound = forge.use('admin', 'users');
bound('show', { user: 1 });           // → forge.api('admin', 'users.show', ...)
BoundForge 接口

forge.use(level, prefix?) 返回的 BoundForge 提供绑定后的快捷方法:

interface BoundForge<LL = Promise<void>> {
  // 可直接调用(= api() 快捷方式)
  (name: string, params?: ApiCallParams): Promise<unknown>;

  // 绑定属性
  readonly level: string;
  readonly prefix?: string;
  levelLoaded: LL;                    // core: Promise<void>

  // 绑定后无需传 level 的方法
  api(name: string, params?: ApiCallParams): Promise<unknown>;
  route(name: string, params?: Record<string, unknown>): string;
  url(name: string, params?: Record<string, unknown>): string;
  hasRoute(name: string): boolean;
  getRoutes(): Record<string, RouteMeta>;
  load(): Promise<void>;
  invalidate(): void;
  isLoaded(): boolean;
  isLoading(): boolean;
  onLoadingChange(cb: LoadingChangeCallback): () => void;

  // BoundForge 独有方法
  onLevelLoaded(): Promise<BoundForge<LL>>;
  onLevelLoaded(onFulfilled: (bound: BoundForge<LL>) => void, onRejected?: (error: unknown) => void): Promise<BoundForge<LL>>;
  useRoutePrefix(prefix: string): BoundForge<LL>;
}

设计意图:forge.use() 作为唯一绑定入口,统一了 Vue useForge(level)、React useForge(level) 和 IIFE 场景的底层实现。用户无论在哪种环境,API 表面完全一致。

4.1.8 加载中标识

核心提供加载状态跟踪能力,通过引用计数器跟踪并发 API 请求数。不内置任何 UI 组件或样式,仅提供状态查询与变更订阅,由框架层或业务层自行消费。

加载状态始终跟踪,无需配置开关。如果不需要使用加载状态,不订阅 onLoadingChange、不调用 isLoading() 即可。

状态查询与订阅
// 查询当前是否处于加载中
forge.isLoading();  // boolean

// 订阅状态变更
const unsub = forge.onLoadingChange((event) => {
  console.log(event.loading);  // true / false
  console.log(event.count);    // 当前并发请求数
});

// 取消订阅
unsub();
LoadingChangeEvent
字段 类型 说明
loading boolean 当前是否仍处于加载中
count number 当前并发请求数
行为规则

设计意图:核心层只负责状态跟踪,不绑定任何 UI 框架或样式体系。Vue/React 包可通过 onLoadingChange 订阅状态变更驱动组件显隐,业务层也可直接使用 isLoading() 做条件判断。这种分层设计避免了路由工具库与 UI 体系的耦合。加载状态始终跟踪,用户不使用则不订阅即可,无需额外配置开关。

4.1.7 Vue 3 集成(@route-forge/vue

// main.ts
import {createApp} from 'vue';
import {createRouteForgePlugin} from '@route-forge/vue';

const app = createApp(App);
const plugin = createRouteForgePlugin({
  /* 同 4.1.1 */
});
app.use(plugin);
// 推荐:ready()(摘要发现 + eager 层级全部完成)后挂载应用
// 失败兜底:摘要端点不可达时 ready() reject,必须接住(否则白屏且无任何提示)
plugin.ready().then(() => {
  app.mount('#app');
}).catch((err) => {
  console.error('[route-forge] init failed', err);
  /* 按业务需要降级:错误页 / 重试 / 上报 */
});
useForge — 核心 composable

useForge() 返回 forge 实例。无 level 时返回完整 RouteForge 实例;传入 level 时内部调用 forge.use(level, prefix?),自动触发 load,提供绑定方法 + levelLoaded 响应式状态:

import {useForge} from '@route-forge/vue';

// 不绑定层级 — 返回完整 RouteForge 实例
const forge = useForge();
forge.api('admin', 'users.show', {user: 1});   // 通过层级 + 路由名调用
forge.ready().then(f => f.use('admin'));        // 等待就绪后绑定层级
forge.use('admin');                              // 绑定层级,返回 BoundForge

// 绑定层级 — 自动触发 load,提供同步方法 + levelLoaded 状态
const forge = useForge('admin');
forge.level;                                    // → 'admin'
forge.levelLoaded;                              // Ref<boolean>
forge('users.show', {user: 1});                // 可直接调用(= forge.api() 快捷方式)
forge.api('users.show');                        // 同上
forge.route('users.show');                      // 生成 URL
forge.url('users.show');                        // route() 语义别名
forge.onLevelLoaded();                          // 等待 level 加载完成
forge.useRoutePrefix('users');                  // 追加路由名前缀

// 绑定层级 + 前缀
const forge = useForge('admin', 'users');
forge('show', {user: 1});                      // → forge.api('admin', 'users.show', ...)
forge.route('show');                            // → forge.route('admin', 'users.show', ...)

// 通用方法(无论是否绑定 level 均可用)
forge.load('admin');                            // 加载层级
forge.isLoaded('admin');                        // 检查缓存
forge.invalidate('admin');                      // 失效缓存
forge.interceptors.request.use(...);            // 拦截器管理

类型定义:

// BoundForge 泛型接口:LL 参数适配不同框架的 levelLoaded 类型
// Vue: BoundForge<Ref<boolean>>, React: BoundForge<boolean>, Core: BoundForge<Promise<void>>
interface BoundForge<LL = Promise<void>> {
  (name: string, params?: ApiCallParams): Promise<unknown>;
  readonly level: string;
  readonly prefix?: string;
  levelLoaded: LL;
  api(name: string, params?: ApiCallParams): Promise<unknown>;
  route(name: string, params?: Record<string, unknown>): string;
  url(name: string, params?: Record<string, unknown>): string;
  hasRoute(name: string): boolean;
  getRoutes(): Record<string, RouteMeta>;
  load(): Promise<void>;
  invalidate(): void;
  isLoaded(): boolean;
  isLoading(): boolean;
  onLoadingChange(cb: LoadingChangeCallback): () => void;
  onLevelLoaded(): Promise<BoundForge<LL>>;
  onLevelLoaded(onFulfilled: (bound: BoundForge<LL>) => void, onRejected?: (error: unknown) => void): Promise<BoundForge<LL>>;
  useRoutePrefix(prefix: string): BoundForge<LL>;
}

// Vue 特化类型
type VueBoundForge = BoundForge<Ref<boolean>>;

// useForge 重载签名
declare function useForge(level: string, prefix: string): VueBoundForge;
declare function useForge(level: string): VueBoundForge;
declare function useForge(): RouteForge;
其他 composable
<script setup lang="ts">
import {useForgeApi, useForgeRoute} from '@route-forge/vue';

// useForgeApi:包装 forge.api(),自动管理 loading/error 状态
const {call, pending, error} = useForgeApi();
const {data} = await call('admin', 'users.show', {user: 123});

// useForgeRoute:响应式 URL 生成器,内部处理 level 加载状态
// level 未加载时返回 '',加载后自动更新
const url = useForgeRoute('public', 'login.show');
</script>

插件提供的完整能力清单:

4.1.7a 组件 ForgeRoute / ForgeLink(vue / react)

两包对称提供 ForgeRoute(通用形态)与 ForgeLink(便捷形态),内部复用各自的 useForgeRouteloaded = href !== ''(level 未加载与渲染期错误都降级为 '',复用该哨兵值)。

4.1.9 初始化时序与推荐模式

Route Forge 的初始化涉及三个独立的异步阶段,理解它们的关系对于正确挂载应用至关重要:

① Auto-discovery(摘要)      ──  获取所有层级的元信息索引
       ·  来源级联:页面内嵌 window.__ROUTE_FORGE__ > createRouteForge({summary}) > 网络 GET {endpoint}(endpoint 缺省走默认 /_forge/routes)
       ·  命中内嵌/配置 → 同步完成、不发网络;命中网络 → 异步回填(见 §4.1.1 / §5.3)
       ↓
② Level load(层级加载)      ──  拉取 eager 层级的完整路由表
       ↓
③ API request(业务请求)      ──  forge.api() 发起的实际业务请求
forge.ready() 方法(唯一初始化等待入口)

v2.0.0 移除了 onSummaryReady 回调,统一走 ready():回调仅有成功通道,其设计曾导致 失败被静默吞掉(ready 挂起 + 白屏无提示)。ready() 提供完整的成功/失败语义链,且 resolve 时机更安全(eager 层级也已完成)。

推荐在 ready() resolve 后挂载应用,确保路由数据已就绪:

// Vue 推荐初始化模式
const app = createApp(App);
const plugin = createRouteForgePlugin({ /* 同 4.1.1 */ });
app.use(plugin);
plugin.ready()
  .then(() => app.mount('#app'))  // 摘要 + eager 层级全部完成后挂载
  .catch((err) => { console.error('[route-forge] init failed', err); });

// React 推荐初始化模式
const forge = createRouteForge({ /* 同 4.1.1 */ });
forge.ready()
  .then(() => {
    ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
  })
  .catch((err) => { console.error('[route-forge] init failed', err); });

如果不等待 ready(),应用可能在路由数据就绪前渲染,此时 route() / hasRoute() 会触发 auto-discovery 守卫错误(见下方)。

forge.ready() 签名与语义

ready() 在 auto-discovery 成功 + eager load 全部尝试完成后 settle,始终返回 Promise<RouteForge>(resolve 值为 forge 实例自身),支持链式调用:

const forge = createRouteForge({ /* ... */ });

// 无参模式:直接 await
await forge.ready();
// 此时 route() / hasRoute() 可安全使用

// 回调模式:onFulfilled / onRejected 内部走 then/catch
forge.ready(
  (f) => { f.use('admin'); },
  (err) => { console.error(err); }
);

// 链式调用:ready 返回 forge 自身
const bound = await forge.ready().then(f => f.use('admin'));
BoundForge.onLevelLoaded() 方法

forge.use(level) 返回的 BoundForge 对象上提供 onLevelLoaded() 方法,采用与 ready() 相同的异步模式。等待当前 level 加载完成后 resolve,resolve 值为 BoundForge 自身:

const bound = forge.use('admin');

// 无参模式:直接 await
await bound.onLevelLoaded();
// 此时 level 已加载,route() / hasRoute() 可安全使用

// 回调模式
bound.onLevelLoaded(
  (b) => { console.log(b.level, 'loaded'); },
  (err) => { console.error(err); }
);

onLevelLoaded() 仅存在于 BoundForge 上(即 forge.use(level) 返回的对象),不能在 forge 顶层直接调用。

Auto-discovery 守卫

当 auto-discovery 尚未完成 未通过 levels 显式声明层级时,route()hasRoute() 会抛出 ForgeError (RF_FE_010),提示用户使用 forge.ready() 等待或 forge.use(level) 完成。

以下方法 不受 守卫影响:

显式声明 levels 时(如 createRouteForge({ levels: ['admin'] })),守卫不生效,因为无需 auto-discovery 即可知道层级存在。

推荐初始化模式
场景 推荐方式
传统 SPA(document 加载后 mount) forge.ready().then(() => app.mount())
异步初始化(如 SSR hydration) await forge.ready() 后 mount
IIFE 浏览器场景 forge.ready().then(f => f.use('admin')) 链式调用
组件级懒加载 useForge(level) / useForgeRoute 内部自动处理

4.2 类型生成(可选)

路由名 → 参数 → 响应的类型声明由后端 route:forge:types Artisan 命令生成(完整规格见 §3.2)。路由定义是后端数据,由后端这个唯一真相源生成,避免前端手写类型与路由表脱节(DESIGN.md §2.4、§6.6):

# 默认输出到 resources/js/types/forge-routes.d.ts
php artisan route:forge:types --out
php artisan route:forge:types --out=src/types/forge-routes.d.ts

生成结果示例:

declare const routes: {
    'admin.users.show': {
        method: 'GET';
        params: { user: string | number };
        // 响应类型默认 unknown,由开发人员通过 映射文件补充
        response: unknown;
    };
    'manage.users.store': {
        method: 'POST';
        params: {};
        body: unknown;
        response: unknown;
    };
};

类型推导链路:forge.api('admin', 'users.show', { user: 123 }) 的参数类型由生成声明约束,路由名字面量错拼在编译期即报错。响应类型默认 unknown,需要业务侧通过单独的响应类型映射文件补全(避免侵入后端代码)。

4.3 Adapter 与内置类 axios 实现

为保证拦截器在所有部署环境下行为一致,Route Forge 提供一个 包内置的类 axios 精简实现作为默认 fallback adapter,无需安装 axios 即可工作。同时支持把宿主项目的 axios 作为 adapter,复用其拦截器与默认配置。

4.3.1 内置 builtin adapter

包内部实现一个轻量级 axios 子集(命名为 @route-forge/builtin-http,不对外单独发布,作为 @route-forge/core的内部模块存在)。能力清单:

内部结构分两层:纯 fetch 底座(超时/取消合并、序列化、非 2xx 转换,fetch-core.ts,无状态可复用) 与拦截器编排 + axios 兼容门面(builtin-http.ts)。元信息拉取统一通道:摘要端点(§3.1.6)与 层级路由表拉取共用同一 requestRaw 通道(经 adapter 分发,跳过业务拦截链)——摘要请求因此获得 timeout、adapter 检测/降级、自定义 Fetcher 兼容;摘要失败错误为 HTTPError/NetworkError (携带 status/url 详情),不再是无超时的裸 fetch

设计原则:

4.3.2 Adapter 选择机制

createRouteForge({ adapter }) 接受以下值:

取值 行为
'auto'(默认) 检测到宿主装了 axios(require.resolve('axios') 成功 / 全局有 axios)→ 用之;否则用内置 builtin adapter
'axios' 强制使用宿主 axios;未安装则抛 AdapterNotFoundError,附安装提示
'builtin' 强制使用内置实现,即使宿主装了 axios 也不复用
自定义 Fetcher 传入符合 Fetcher 接口(见 4.3.3)的对象,绕过 auto 检测

auto 检测的执行时机:

  1. createRouteForge() 同步阶段尝试检测。
  2. 检测方式:优先 import('axios') 动态探测(ESM 友好);失败则查全局 window.axios(浏览器场景,便于 CDN 引入);仍失败则用内置实现。
  3. 选定后不再切换;如运行时宿主才装上 axios,需要重新 createRouteForge()

设计意图:兼顾「已用 axios 的存量项目」与「零依赖的新项目」。前者自动复用已有 axios 实例(含其拦截器、baseURLdefault.headers 等配置),后者无需任何额外安装即可开箱。

4.3.3 自定义 Fetcher 接口

不想用 axios 也不想用内置实现的用户,可传一个对象作为 adapter:

type Fetcher = {
    request(config: RequestConfig): Promise<ResponseData>;
    interceptors?: {
        request?: InterceptorManager<RequestConfig>;
        response?: InterceptorManager<ResponseData>;
    };
};

// 使用示例
const forge = createRouteForge({
    adapter: {
        async request(config) {
            const res = await myKyInst(config.url, {method: config.method, ...});
            return {route: config.route, /* ... 其他 ResponseData 字段 */};
        },
        // 拦截器管理可选;forge.interceptors.* 的拦截链由 core 在 adapter 外围统一执行,与此字段无关
        interceptors: undefined,
    },
});

约束:

4.3.4 与 axios 宿主实例的关系

adapter: 'auto' 检测到宿主 axios 时:

设计意图:把 Route Forge 视为「在已有 axios 之上叠加的路由层」,而非替代宿主 HTTP 客户端。已有 axios 配置保持不变,Route Forge 只负责路由解析与命名调用,把 HTTP 细节交给宿主。

5. 配置项参考

5.1 后端 config/forge.php

类型 默认值 说明
levels array<string, LevelConfig> 见 3.1.2 层级定义表,键为层级名(自定义),值为该层级的匹配规则与缓存策略
levels.{name}.description string '' 层级描述,仅用于文档与调试输出
levels.{name}.match.prefix string[] [] URI 前缀匹配列表,命中任一即归入此层级
levels.{name}.match.middleware string[] [] 中间件匹配列表,匹配逻辑受 middleware_match 控制
levels.{name}.match.middleware_match string|array 'any' 中间件匹配模式:'any'(OR)/ 'all'(AND)/ DNF 数组(见 §3.1.2 中间件匹配模式)
levels.{name}.load 'eager'|'lazy' 'lazy' 是否在摘要端点中标记为「前端应预加载」;前端自动发现时据此决定预加载策略
levels.{name}.cache int|null null 该层级元信息缓存 TTL(秒);null 不缓存,0 永久缓存。⚠️ 0 遵循 Laravel Cache TTL 惯例(永久),非 HTTP Cache-Control: max-age=0 含义。缓存仅通过包内部管理,不使用 HTTP 响应头
endpoint_prefix string '/_forge/routes' 路由元信息对外端点前缀(同时用于层级端点和摘要端点)
url_prefix string '' URL 前缀,通过摘要端点 config.url_prefix 下发给前端。支持路径前缀(如 '/api/v1')和完整 URL(如 'https://api.example.com',此时忽略客户端 baseURL)。为空时不拼接
cache_driver string|null null 缓存驱动;null 用默认驱动,可指定 redis/file/array
strict_mode bool false 严格模式;未命中层级时抛异常(true)或归入 fallback/unassigned(false)
fallback_level string|null null 兜底层级名;null 时未命中路由归入「未分配」分组(可通过摘要端点 §3.1.6 获取);非 null 则归入指定层级
classifier callable|null null 自定义分类回调,签名 fn(Route $r): ?string,返回层级名或 null

5.2 前端 createRouteForge 配置

类型 默认值 说明
endpoint string(可选) /_forge/routes 摘要端点 URL(网络来源)。可省略:三源(endpoint / summary / 页面内嵌 window.__ROUTE_FORGE__)皆无时,网络引导回退到与后端约定的默认摘要端点 /_forge/routes(见 §4.1.1 级联)
summary SummaryResponse(可选) 直接提供摘要数据(如测试/非全局引导),跳过摘要 HTTP;优先级低于页面内嵌 window.__ROUTE_FORGE__
levels string[] 自动发现 声明存在的层级名列表;未传时通过摘要端点(§3.1.6)自动获取
eager string[] 自动发现 初始化时立即拉取的层级;未传时读取摘要端点返回的 load: 'eager' 标记
adapter 'auto'|'axios'|'builtin'|Fetcher 'auto' 详见 §4.3.2;必须在 createRouteForge() 调用前确定,调用后不再切换
cache.ttl number 3600 前端本地兜底缓存 TTL(秒);后端 config.cache_ttl 为全局上限,前端只能缩短不能延长(config.cache_ttl=null 时不缓存)
cache.storage 'memory'|'sessionStorage'|'localStorage' 'memory' 缓存存储介质
interceptors.request Fn | [resolve?, reject?] | { resolve?, reject? } 声明式请求拦截器(单个),支持函数/元组/对象三写法;多拦截器用运行时 use()(见 §4.1.1)
interceptors.response Fn | [resolve?, reject?] | { resolve?, reject? } 声明式响应拦截器(单个),形状同上;多拦截器用运行时 use()
strict boolean false @deprecated 前端校验始终开启,此选项不被消费(见 §4.1.5)
timeout number 30000 默认请求超时(毫秒)
baseURL string '' 前端 baseURL;为空时使用相对路径

5.3 配置覆盖关系

配置来源分三层,优先级从高到低:

① 后端摘要下发(同一份 SummaryResponse,来源三选一:内嵌 window.__ROUTE_FORGE__ > createRouteForge.summary > GET {endpoint})
↓ 可被细化
② 前端 createRouteForge 显式配置
↓ 仅限路由参数/query/body
③ 单次 forge.api(level, name, params) 调用参数(不能覆盖全局规则)

分级覆盖策略(非一刀切,按配置项性质区分):

配置项 后端摘要端点 前端配置 覆盖规则
strict_mode config.strict_mode strict(已废弃) 安全相关:后端为权威值。strict_mode 决定后端生成 manifest 时未命中路由的处理方式(抛异常 vs 归入 fallback/unassigned)。前端校验始终开启,不受此开关影响(见 §4.1.5)
cache_ttl config.cache_ttl(全局) cache.ttl 性能相关:后端全局 TTL 为上限,前端可缩短(如后端 3600 前端设 1800)不能延长。config.cache_ttl=null(不缓存)时前端也不落缓存
endpoint_prefix / endpoint config.endpoint_prefix endpoint 连接相关:层级懒加载优先用摘要 levels[].route.uriendpoint_prefix 仅作 route.uri 缺省时的兜底;网络来源时前端 endpoint 与之对齐
url_prefix config.url_prefix URL 构建相关:后端为权威值。支持路径前缀(拼接在 baseURL 后)和完整 URL(含协议+域名,此时忽略 baseURL)。null 时不拼接
levels levels 键列表 levels 数组 发现相关:前端未传 levels 时自动从摘要发现(含恒存在的 unassigned 层级);显式传入时取与后端交集(前端不能声明后端不存在的层级)
eager levels[name].load eager 数组 加载相关:前端未传 eager 时自动取后端 load: 'eager' 的层级;显式传入时取并集(前端可额外预加载后端标记为 lazy 的层级)
interceptors.* interceptors 纯前端:后端不下发拦截器配置

摘要端点返回的 config 字段示例:

{
  "config": {
    "strict_mode": false,
    "endpoint_prefix": "/_forge/routes",
    "url_prefix": null,
    "cache_ttl": 3600
  }
}

前端初始化流程(摘要来源级联,SPEC §4.1.1 / §3.1.8):

  1. 解析摘要来源:若存在页面内嵌 window.__ROUTE_FORGE__ → 消费它(一次性读取、自删、memo);否则若有 createRouteForge({ summary }) → 用它;否则走网络 GET {endpoint} 拉摘要,endpoint 缺省时使用与后端约定的默认摘要端点 /_forge/routes(不再因三源皆无而抛 TypeError)。options 参数本身可省略(createRouteForge() 等价 createRouteForge({})),摘要全来自内嵌时可直接无参调用。
  2. 命中内嵌/配置摘要时,autoDiscovery 同步完成(构造后 route()/ready() 立即可用);命中网络时按响应回填。
  3. 折算出的 config 作为最高优先级覆盖前端配置;levels 用于发现层级与 eager 标记;各层级 route.uri 供懒加载拼 URL。

设计意图:后端配置始终权威(避免前后端手动同步出错),同时保留前端灵活度(缓存可缩短、eager 可扩展)。SPA 独立部署 / Vite dev 场景无内嵌 → 自动回落网络摘要;Laravel 直出首页可选内嵌加速。

6. 错误码

所有 Route Forge 抛出的错误都继承自 ForgeError,附带 coderoutelevelcontext 字段,便于调用方 catch 后统一处理。

6.1 后端错误

错误类 code 触发场景 HTTP 状态
RouteTierNotAssignedException RF_BE_001 strict_mode=true 且路由未命中任何层级 500
UnknownLevelException RF_BE_002 请求的层级名不在 levels 配置中 404
CacheDriverException RF_BE_003 指定的 cache_driver 不可用 500
ClassifierException RF_BE_004 classifier 回调抛错 500

6.2 前端错误

错误类 code 触发场景
UnknownRouteError RF_FE_001 路由名不存在于已加载层级中
UnknownLevelError RF_FE_002 路由所在层级未在 levels 声明
MissingRouteParamError RF_FE_003 必填路径参数缺失(无默认值时,始终校验)
InvalidPathParamError RF_FE_003 路径参数收到非原始值(对象/数组)
AdapterNotFoundError RF_FE_005 adapter: 'axios' 但未检测到 axios
InvalidInterceptorReturnError RF_FE_006 请求拦截器返回非 RequestConfig
NetworkError RF_FE_007 adapter 抛出的网络错误(DNS、连接超时等)
HTTPError RF_FE_008 HTTP 非 2xx 且未被 onRejected 拦截器恢复;response 携带完整 ResponseData 供逐段检查响应体
RequestAbortedError RF_FE_009 请求被 AbortSignal 取消
ForgeError(守卫) RF_FE_010 auto-discovery 未完成时调用 route() / hasRoute()(见 §4.1.9)

6.3 错误对象结构

class ForgeError extends Error {
    // v2.0.0 起收窄为字面量联合 ForgeErrorCode(RF_FE_001~010,004 空缺),
    // switch (e.code) 可获得穷尽检查;类型可从 '@route-forge/core' 导入
    readonly code: ForgeErrorCode;  // 如 'RF_FE_003'
    readonly route?: string;      // 触发错误的路由名(如适用)
    readonly level?: string;      // 触发错误的层级(如适用)
    readonly context?: Record<string, unknown>;  // 额外上下文(如缺失的参数名、HTTP 状态等)
    readonly cause?: unknown;     // 原始错误(如 adapter 抛出的底层错误)
    // 仅 HTTPError (RF_FE_008):完整 ResponseData(status/headers/data/config),
    // 随错误逐段传递(响应拦截器 onRejected 链 → 最终 catch),供调用方检查响应体
    readonly response?: ResponseData;
}

约定:

7. 测试矩阵

7.1 后端测试(PHPUnit)

测试维度 覆盖点
层级分配 显式 ->tier()、配置 match、Route::group 透传、classifier、fallback/unassigned、优先级覆盖、多层级同时命中取最后一个
Artisan 命令 route:forge:list 输出格式(table/json)、按层级过滤、unassigned 路由显示、--level 参数过滤
Artisan 命令 route:forge:types 生成 d.ts 结构、--level 过滤、--json 输出、--out 写文件
中间件匹配 middleware_match 简单模式(any/all)、高级模式(DNF 数组)、边界情况(空数组、单元素)
端点响应 /_forge/routes/{level} 返回结构、/_forge/routes 摘要端点返回结构、缓存命中、未声明层级 404
严格模式 strict_mode=true 未命中抛异常、false + fallback_level=null 归入 unassigned、false + fallback_level 非 null 归入指定层级
Laravel 兼容 Laravel 9/10/11 三版本矩阵;资源路由、嵌套 group、命名空间
缓存 cache_driver 各驱动(redis/file/array)、TTL 过期、手动失效、0 永久缓存

7.2 前端测试(Vitest)

测试维度 覆盖点
懒加载 隐式懒加载、显式 load(level)、并发去重、invalidate 失效
自动发现 摘要端点获取 levels、自动识别 eager 标记、config 字段分级覆盖逻辑、url_prefix 下发后拼接到路由 URL
缓存 三种 storage、TTL 优先级(后端 > 前端兜底)、跨层级隔离
拦截器 多段串联、注册顺序执行、onFulfilled/onRejected 链、eject/clear、async 拦截器、函数/元组/对象三种声明形式
调用 路由校验(参数缺失/路由名不存在/层级未声明)、路径参数填充、parameter_defaults 默认值填充、query/body 拼装、方法自动选取
Adapter auto 检测、builtin adapter 行为、axios 复用、自定义 Fetcher
类型生成 route:forge:types 生成的声明约束 forge.api(level, name, params) 调用(路由名字面量校验、参数类型校验,见 §3.2)

7.3 端到端测试

最小示例项目(Laravel + Vue)覆盖完整链路:

  1. 后端定义 admin/manage/client 三层级路由。
  2. 前端 createRouteForge() 初始化、登录、按层级懒加载、调用 API。
  3. 验证:路由名错拼编译期报错、按层级懒加载正常、API 调用成功。

8. 版本与发布

8.1 v1.0 能力清单(MVP)

8.2 v1.x 路线图

8.3 兼容性承诺