@route-forge/core 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,335 +1,407 @@
1
1
  # @route-forge/core
2
2
 
3
- 框架无关的命名路由客户端核心:分级懒加载、隔离缓存、并发去重、拦截器。
3
+ **English** | [中文](./README_zh.md)
4
4
 
5
- ## 安装
5
+ The framework-agnostic client core for Laravel named routes: fetches route metadata from a backend manifest endpoint, lazy-loads and caches it per level, calls APIs **by route name**, and builds URLs — all with TypeScript type safety and axios-compatible interceptors.
6
+
7
+ ## What it does
8
+
9
+ - **Call APIs by route name**: `forge.api('admin', 'users.show', { user: 123 })` — no hardcoded paths
10
+ - **Tiered lazy loading**: route metadata is grouped into levels (e.g. `public` / `admin`) and fetched on demand
11
+ - **Isolated cache + request deduplication**: per-level cache (memory / sessionStorage / localStorage with TTL); concurrent fetches of the same level are merged into one request
12
+ - **Auto-discovery**: on startup it fetches the summary endpoint to discover levels, eager tiers, and the URL prefix
13
+ - **Interceptors**: request / response chains with axios-compatible `use` / `eject` / `clear` (request LIFO, response FIFO)
14
+ - **Request cancellation**: `forge.api()` returns a `ForgeRequest` with a built-in `abort()` that cooperates with timeouts
15
+ - **Loading-state tracking**: in-flight request counter + subscriptions, ready to drive a global loading indicator
16
+ - **Type safety**: `ForgeRouteMap` two-level mapping (codegen or module augmentation) gives compile-time checks for route names, params, and responses
17
+ - **Pluggable transport**: zero-dependency built-in `fetch` implementation (default), host axios reuse, or a custom `Fetcher`
18
+ - **Plain `<script>` usage**: an IIFE build is provided for direct browser inclusion, no bundler required
19
+
20
+ ## Installation
6
21
 
7
22
  ```bash
8
23
  pnpm add @route-forge/core
9
- # 可选:axios 适配器
24
+ # Optional: if axios is installed in the host project and adapter is 'auto' (default),
25
+ # it is detected and reused automatically; install it explicitly to force 'axios' mode
10
26
  pnpm add axios
11
27
  ```
12
28
 
13
- ## 基本用法
29
+ ## Quick start
14
30
 
15
31
  ```ts
16
32
  import { createRouteForge } from '@route-forge/core'
17
33
 
18
34
  const forge = createRouteForge({
19
- endpoint: '/_forge/routes',
35
+ endpoint: '/_forge/routes', // backend manifest endpoint
20
36
  })
21
37
 
22
- // 调用 API(自动加载层级 + 填充参数 + 发送请求)
38
+ // Call an API (auto-discovers lazy-loads the level → fills path params → sends the request)
23
39
  const user = await forge.api('admin', 'users.show', { user: 123 })
24
40
 
25
- // 生成 URL(仅拼路径,不发请求)
26
- const url = forge.route('public', 'login.show')
27
- // '/login'
41
+ // Build URLs only (no request is sent)
42
+ const url = forge.route('public', 'login.show') // → '/login'
43
+ const url2 = forge.url('public', 'login.show') // url() is a semantic alias of route()
44
+
45
+ // Route existence / metadata inspection
46
+ forge.hasRoute('admin', 'users.show') // true / false
47
+ forge.getRoutes('admin') // snapshot of one level (deep copy)
48
+ forge.getRoutes() // all loaded levels, grouped by level
49
+
50
+ // Level loading & cache management
51
+ await forge.load('admin') // load a level (concurrent calls deduplicated)
52
+ forge.isLoaded('admin') // is the level cached?
53
+ forge.invalidate('admin') // invalidate one level
54
+ forge.invalidate(['admin', 'manage']) // invalidate several
55
+ forge.invalidate() // invalidate all
56
+ ```
28
57
 
29
- // url() route() 的语义别名
30
- const url2 = forge.url('public', 'login.show')
58
+ ## Initialization sequence & `ready()`
31
59
 
32
- // 检查路由是否存在
33
- forge.hasRoute('admin', 'users.show') // true / false
60
+ `createRouteForge()` immediately starts **auto-discovery** (summary endpoint) in the background, then preloads the **eager** levels. `ready()` resolves once both are done (it resolves with the forge instance itself, so chaining works):
34
61
 
35
- // 获取路由元信息
36
- const routes = forge.getRoutes('admin') // 指定层级
37
- const allRoutes = forge.getRoutes() // 全部层级
62
+ ```ts
63
+ const forge = createRouteForge({ endpoint: '/_forge/routes' })
38
64
 
39
- // 检查层级是否已加载
40
- if (!forge.isLoaded('admin')) {
41
- await forge.load('admin')
42
- }
65
+ // Recommended: mount the app after ready() — sync methods like route()/hasRoute() are then safe
66
+ forge.ready()
67
+ .then(() => app.mount('#app'))
68
+ .catch((err) => {
69
+ // Always handle this: ready() rejects when the summary endpoint is unreachable
70
+ // and no explicit levels were given — otherwise users face a silent blank page
71
+ console.error('[route-forge] init failed', err)
72
+ })
73
+
74
+ // Callback style: onFulfilled / onRejected (still returns a Promise)
75
+ forge.ready(
76
+ (f) => console.log('ready!', f),
77
+ (err) => console.error(err),
78
+ )
43
79
 
44
- // 失效缓存
45
- forge.invalidate('admin') // 失效指定层级
46
- forge.invalidate(['admin', 'manage']) // 批量失效多个层级
47
- forge.invalidate() // 失效全部
80
+ // async/await style
81
+ await forge.ready()
48
82
  ```
49
83
 
50
- ## URL 前缀(`url_prefix`)
84
+ The three loading phases and how to track them:
51
85
 
52
- 后端可在摘要端点 `config.url_prefix` 中下发 URL 前缀,前端生成路由 URL 时自动拼接,无需手动配置。
86
+ | Phase | Description | Tracking |
87
+ |-------|-------------|----------|
88
+ | Auto-discovery | fetch the summary endpoint, discover levels/config | `forge.ready()` |
89
+ | Level load | fetch one level's route metadata | `forge.isLoaded(level)` / `bound.onLevelLoaded()` |
90
+ | API request | business API calls | `forge.isLoading()` / `forge.onLoadingChange()` |
53
91
 
54
- 支持两种形式:
92
+ **Degradation rule**: if explicit `levels` were provided, an unreachable summary endpoint logs a `console.warn` and falls back to the explicit configuration; without explicit `levels` there is no fallback and `ready()` rejects (with `HTTPError` / `NetworkError` / `UnknownLevelError`).
55
93
 
56
- ```ts
57
- // 1. 路径前缀 — 拼接在 baseURL 之后、路由 URI 之前
58
- // 后端返回 { "config": { "url_prefix": "/api/v1" } }
59
- forge.route('public', 'users.show', { user: 1 })
60
- // '/api/v1/users/1'
61
-
62
- // 2. 完整 URL(含协议+域名)— 直接作为基础 URL,忽略客户端 baseURL
63
- // 适用于前后端不同域名的场景
64
- // 后端返回 { "config": { "url_prefix": "https://api.example.com" } }
65
- forge.route('public', 'users.show', { user: 1 })
66
- // 'https://api.example.com/users/1'
67
- ```
94
+ **Guard**: while auto-discovery has not completed and no explicit `levels` exist, `route()` / `hasRoute()` throw `ForgeError (RF_FE_010)` to prevent wrong results from unready data; `api()` is unaffected (it awaits discovery internally).
95
+
96
+ ## Options (`createRouteForge(options)`)
97
+
98
+ | Option | Type | Default | Description |
99
+ |--------|------|---------|-------------|
100
+ | `endpoint` | `string` | — | summary/manifest endpoint path (network source). Optional: at least one of `endpoint`, `summary`, or an embedded `window.__ROUTE_FORGE__` must exist, otherwise `createRouteForge` throws `TypeError` |
101
+ | `summary` | `SummaryResponse` | — | Provide the summary directly (tests / non-global bootstrap), skipping the summary HTTP request. Takes lower priority than an embedded `window.__ROUTE_FORGE__` |
102
+ | `levels` | `string[]` | auto-discovered | discovered from the summary when omitted; when given, intersected with the backend summary (the frontend cannot declare levels the backend doesn't know) |
103
+ | `eager` | `string[]` | backend `load:'eager'` levels | levels preloaded after discovery; union with the backend marks when given |
104
+ | `adapter` | `'auto' \| 'axios' \| 'builtin' \| Fetcher` | `'auto'` | see "Adapters" below |
105
+ | `cache.ttl` | `number` (seconds) | `3600` | frontend fallback TTL; the backend's global `config.cache_ttl` is the ceiling — the effective TTL is `min(backend, frontend)` (frontend may shorten but never extend; `0` = forever; `config.cache_ttl: null` = don't cache) |
106
+ | `cache.storage` | `'memory' \| 'sessionStorage' \| 'localStorage'` | `'memory'` | cache backend; storage modes keep an in-memory mirror and invalidate cross-tab writes via `storage` events |
107
+ | `interceptors.request` | array | none | declarative request interceptors: plain function (treated as `onFulfilled`) or `[onFulfilled?, onRejected?]` tuple |
108
+ | `interceptors.response` | array | none | declarative response interceptors, same shapes |
109
+ | `timeout` | `number` (ms) | `30000` | global timeout; a single call can override it via `params.timeout` |
110
+ | `baseURL` | `string` | `''` | base prepended to every generated URL |
111
+ | `strict` | `boolean` | — | **Deprecated, ignored.** Frontend validation is always on (unknown level → `UnknownLevelError`, unknown route → `UnknownRouteError`, missing required param → `MissingRouteParamError`); silently ignoring typos hides bugs. The backend's `strict_mode` is a manifest-generation concern and unrelated to the frontend |
112
+
113
+ ## Embedded bootstrap (optional hydration)
114
+
115
+ Summary discovery reads from one source in this cascade: **embedded `window.__ROUTE_FORGE__` → `createRouteForge({ summary })` → network `GET {endpoint}`**. All three deliver the same `SummaryResponse`.
68
116
 
69
- > `url_prefix` 为后端权威,前端不能覆盖。不下发或为空字符串时不影响 URL 生成。
117
+ For Laravel/Blade server-rendered first pages, the backend `@forgeSummary` directive inlines the summary as a one-shot, non-enumerable `window.__ROUTE_FORGE__` accessor that self-deletes on first read. When core finds it, it **skips the summary HTTP round-trip and completes discovery synchronously** — `route()` / `ready()` work immediately after `createRouteForge()` returns, eliminating the "routes not ready" first-paint flash. Level route tables are still lazy-loaded per level over HTTP (protected routes never enter the public HTML). A module-level memo lets a second instance (React StrictMode / a second provider) reuse the summary after the global is gone.
70
118
 
71
- ## 参数智能解析
119
+ If no embed exists (standalone SPA, Vite dev), core falls back to the network summary automatically. `createRouteForge({ summary })` is the explicit, test/SSR-friendly entry.
72
120
 
73
- `forge.api()` 的第二参数支持四种数据类型:路径参数(平铺)、`query`(查询参数)、`body`(请求体)、`headers`
74
- (请求头)。同时提供 `params` 固定 key 用于显式指定路径参数。
121
+ > Honest scope: the one-shot self-delete only shrinks the summary's runtime footprint on `window`; the data is still present in the HTML source. This is a latency/flash optimization, **not** an XSS- or network-egavesdropping-proof boundary.
75
122
 
76
- 当路径参数名与 `query`/`body`/`headers` 冲突时,按类型智能消解:
123
+ ## Smart parameter resolution
124
+
125
+ The third argument `params` of `forge.api(level, name, params)` carries four kinds of data — path parameters (flattened), `query`, `body`, `headers` — plus `timeout` (per-call override) and the explicit `params` key:
77
126
 
78
127
  ```ts
79
- // 向后兼容:原有写法不变
128
+ // Flattened path params + query string
80
129
  forge.api('admin', 'users.show', { user: 1, query: { include: 'posts' } })
81
130
 
82
- // 冲突消解:query string 自动识别为路径参数
83
- // 路由: /search/{query}
131
+ // Conflict resolution: route /search/{query} a string `query` is detected as a path param
84
132
  forge.api('admin', 'search.show', { query: 'keyword' })
85
133
  // → URL: /search/keyword
86
134
 
87
- // 显式 params:同时需要路径参数和 query string
135
+ // Explicit params: need BOTH a path param and a query string (`params` wins)
88
136
  forge.api('admin', 'search.show', {
89
- params: { query: 'keyword' }, // → 替换 {query}
137
+ params: { query: 'keyword' }, // → fills the {query} placeholder
90
138
  query: { page: 1 }, // → query string
91
- body: { detailed: true }, // → 请求体
139
+ body: { detailed: true }, // → request body
140
+ headers: { 'X-Trace': 'a1' }, // → request headers
141
+ timeout: 120_000, // → per-call timeout override (default 30s)
92
142
  })
93
143
  ```
94
144
 
95
- 规则:`params` 优先 > 平铺 `string|number` → 路径参数 > 对象类型按原定义(`query`/`body`/`headers`)。
145
+ Resolution rules (by priority):
146
+
147
+ 1. Explicit `params` → path parameters, highest priority
148
+ 2. Remaining flattened keys → path parameters (they never overwrite keys already present in `params`)
149
+ 3. Fixed keys resolved by value type: object `query` / `headers` → their fixed purpose; `string|number` → path parameter; `body` non-`string|number` → request body, `string|number` → path parameter
150
+ 4. Optional URI params (`{param?}`) become empty segments when missing (extra `/` cleaned up); backend-provided `parameter_defaults` fill in for missing params
151
+
152
+ ## URL prefix (`url_prefix`)
153
+
154
+ The backend may deliver a URL prefix via `config.url_prefix` in the summary endpoint; generated URLs automatically include it:
155
+
156
+ ```ts
157
+ // 1. Path prefix — inserted after baseURL, before the route URI
158
+ // backend returns { "config": { "url_prefix": "/api/v1" } }
159
+ forge.route('public', 'users.show', { user: 1 }) // → '/api/v1/users/1'
160
+
161
+ // 2. Full URL (protocol + host) — used as the base URL, the client's baseURL is ignored;
162
+ // ideal when frontend and backend live on different origins
163
+ // backend returns { "config": { "url_prefix": "https://api.example.com" } }
164
+ forge.route('public', 'users.show', { user: 1 }) // → 'https://api.example.com/users/1'
165
+ ```
166
+
167
+ > `url_prefix` is backend-authoritative; the frontend cannot override it. An absent or empty prefix leaves URLs unchanged.
96
168
 
97
- ## 单次超时覆盖
169
+ ## Level binding: `forge.use(level, prefix?)`
98
170
 
99
- `forge.api()` 支持通过 `timeout` 参数覆盖单次请求的超时时间(毫秒),不传时使用
100
- `createRouteForge({ timeout })` 的全局值(默认 30s):
171
+ `use()` is the single level-binding entry point and returns a `BoundForge` — Vue / React / IIFE share the exact same API surface:
101
172
 
102
173
  ```ts
103
- // 全局超时 30s,单次导出请求覆盖为 120s
104
- forge.api('admin', 'reports.export', { timeout: 120_000 })
174
+ // Bind a level — triggers load automatically, exposes shortcuts
175
+ const bound = forge.use('admin')
176
+ bound('users.show', { user: 1 }) // callable directly (= bound.api())
177
+ bound.route('users.show') // URL generation
178
+ bound.level // → 'admin'
179
+ bound.levelLoaded // Promise<void> in core (Vue/React specialize this)
180
+
181
+ // Bind level + prefix — route names are joined automatically
182
+ // (ambiguity is resolved smartly: prefer prefix.suffix, fall back to suffix itself)
183
+ const users = forge.use('admin', 'users')
184
+ users('show', { user: 1 }) // → forge.api('admin', 'users.show', ...)
185
+
186
+ // Other BoundForge methods
187
+ await bound.onLevelLoaded() // wait until the level is loaded (callback form supported)
188
+ bound.hasRoute('users.show') // existence check within the bound level
189
+ bound.useRoutePrefix('posts') // returns a NEW BoundForge with the new prefix (original unchanged)
190
+ // Generic methods act on the bound level: bound.load() / bound.invalidate() / bound.isLoaded()
191
+ // Global methods still work: bound.isLoading() / bound.onLoadingChange()
105
192
  ```
106
193
 
107
- ## 认证(Authentication)
194
+ > Every `use()` call returns a fresh `BoundForge` (not cached); `forge.use()` without arguments returns the forge itself.
108
195
 
109
- Route Forge 不内置登录态管理,认证逻辑通过拦截器实现,灵活且完全可控。
196
+ ## Request cancellation
110
197
 
111
- ### Token 注入
198
+ `forge.api()` returns a `ForgeRequest` — a `Promise` with an extra `abort()` method; the internal `AbortController` is managed for you:
112
199
 
113
200
  ```ts
114
- // 声明式(初始化时配置)
201
+ const req = forge.api('admin', 'reports.export', { timeout: 120_000 })
202
+ req.abort() // the request is aborted; the Promise rejects with RequestAbortedError (RF_FE_009)
203
+ ```
204
+
205
+ `abort()` and the timeout (`AbortSignal.timeout`) cooperate — whichever fires first cancels the request. Interceptors can read the AbortSignal via `config.signal`.
206
+
207
+ ## Interceptors & authentication
208
+
209
+ The interceptor API matches axios (`use` / `eject` / `clear`); request interceptors run **LIFO** (last registered, first executed), response interceptors **FIFO**. Route Forge ships no built-in session management — auth is done via interceptors:
210
+
211
+ ```ts
212
+ // Declarative (at initialization)
115
213
  const forge = createRouteForge({
116
214
  endpoint: '/_forge/routes',
117
215
  interceptors: {
118
216
  request: [
119
217
  (config) => {
120
218
  const token = authStore.getToken()
121
- if (token) {
122
- config.headers.Authorization = `Bearer ${token}`
123
- }
124
- return config
219
+ if (token) config.headers.Authorization = `Bearer ${token}`
220
+ return config // must return a RequestConfig object, otherwise RF_FE_006 is thrown
125
221
  },
126
222
  ],
223
+ response: [
224
+ (resp) => resp.data, // unwrap: api() resolves with business data directly
225
+ [undefined, (err) => { // tuple form: [onFulfilled?, onRejected?]
226
+ if (err instanceof HTTPError && err.context?.status === 401) {
227
+ authStore.logout()
228
+ window.location.href = '/login'
229
+ }
230
+ return Promise.reject(err)
231
+ }],
232
+ ],
127
233
  },
128
234
  })
129
235
 
130
- // 或运行时动态注册
131
- forge.interceptors.request.use((config) => {
132
- const token = authStore.getToken()
133
- if (token) {
134
- config.headers.Authorization = `Bearer ${token}`
135
- }
136
- return config
137
- })
236
+ // Runtime registration / removal / clearing
237
+ const id = forge.interceptors.request.use((config) => { /* ... */ return config })
238
+ forge.interceptors.request.eject(id)
239
+ forge.interceptors.request.clear()
240
+ forge.interceptors.response.clear()
138
241
  ```
139
242
 
140
- ### 401 响应处理
141
-
142
- ```ts
143
- forge.interceptors.response.use(
144
- (res) => res, // 2xx 正常通过
145
- (err) => {
146
- if (err instanceof HTTPError && err.context?.status === 401) {
147
- authStore.logout()
148
- window.location.href = '/login'
149
- }
150
- return Promise.reject(err)
151
- },
152
- )
153
- ```
154
-
155
- ### 登出清理
243
+ **Logout cleanup** example:
156
244
 
157
245
  ```ts
158
246
  function logout() {
159
247
  authStore.clearToken()
160
- forge.invalidate() // 清空路由缓存
161
- forge.interceptors.request.clear() // 清空拦截器
248
+ forge.invalidate() // clear the route cache
249
+ forge.interceptors.request.clear() // clear interceptors
162
250
  forge.interceptors.response.clear()
163
251
  }
164
252
  ```
165
253
 
166
- > 拦截器 API axios 完全一致(`use` / `eject` / `clear`),如果项目已使用 axios 拦截器,可跳过此节——
167
- > `adapter: 'auto'` 模式下宿主 axios 的拦截器会自动生效。
254
+ > With `adapter: 'auto'` reusing host axios, interceptors already registered on the host axios instance run first; Route Forge interceptors run after them.
255
+ > Metadata fetching (summary / level tables) goes through the adapter's raw channel and never passes the business interceptor chains, so unwrapping interceptors can't corrupt it.
168
256
 
169
- ## 加载状态跟踪
257
+ ## Loading-state tracking
170
258
 
171
- 核心始终跟踪并发 API 请求的加载状态,无需配置。不需要使用时,不调用相关 API 即可。
259
+ The core always tracks concurrent API requests; there is nothing to configure — just don't subscribe if you don't need it:
172
260
 
173
261
  ```ts
174
- // 查询当前是否处于加载中
175
- forge.isLoading() // boolean
262
+ forge.isLoading() // boolean: any request in flight?
176
263
 
177
- // 订阅状态变更
178
264
  const unsub = forge.onLoadingChange((event) => {
179
265
  console.log(event.loading) // true / false
180
- console.log(event.count) // 当前并发请求数
266
+ console.log(event.count) // current number of concurrent requests
181
267
  })
182
-
183
- // 取消订阅
184
- unsub()
268
+ unsub() // unsubscribe
185
269
  ```
186
270
 
187
- > 加载状态始终跟踪,用户不使用则不订阅即可。Vue/React 包可通过 `onLoadingChange` 订阅状态变更驱动组件显隐。
188
-
189
- ## 初始化合时序与推荐模式
271
+ The Vue / React packages can drive component-level loading indicators from `onLoadingChange`.
190
272
 
191
- ### 三种加载状态
273
+ ## Type safety (optional but recommended)
192
274
 
193
- | 类型 | 说明 | 跟踪方式 |
194
- |----------------|--------------------------------|-----------------------------|
195
- | Auto-discovery | 拉取摘要端点发现 levels/config | 内部 `autoDiscoveryPromise` |
196
- | Level load | 拉取某层级路由元数据 | `forge.isLoaded(level)` |
197
- | API request | 业务接口请求 | `forge.isLoading()` |
275
+ `ForgeRouteMap` is a two-level mapping interface: level → route name → metadata. Once defined, **level / route name / params are inferred automatically** in `useForge` / `useForgeApi` / `bound()` calls — a typo'd route name becomes a compile error.
198
276
 
199
- ### `ready()` 后挂载应用
277
+ Two ways to define it:
200
278
 
201
- 推荐在 `ready()` resolve 后挂载应用(auto-discovery + eager 层级全部完成),
202
- 失败走 `catch`(onSummaryReady 回调已移除,统一走 ready——完整成功/失败语义链):
279
+ ```bash
280
+ # Option 1: codegen CLI (fetches the backend manifest, emits a .d.ts)
281
+ npx route-forge-codegen \
282
+ --endpoint http://localhost/_forge/routes \
283
+ --out src/types/forge-routes.d.ts \
284
+ [--levels public,admin] [--responseTypes path/to/map.json]
285
+ ```
203
286
 
204
287
  ```ts
205
- const forge = createRouteForge({
206
- endpoint: '/_forge/routes',
207
- })
208
- // 成功:摘要 + eager 完成后挂载;失败:接住 reject,避免静默白屏
209
- forge.ready()
210
- .then(() => app.mount('#app'))
211
- .catch((err) => {
212
- console.error('[route-forge] init failed', err)
213
- // 按业务需要降级:渲染错误页 / 重试 / 上报
214
- })
288
+ // Option 2: TypeScript module augmentation
289
+ declare module '@route-forge/core' {
290
+ interface ForgeRouteMap {
291
+ admin: {
292
+ 'users.show': { method: 'GET'; params: { user: string | number }; response: User }
293
+ 'users.index': { method: 'GET'; params: {}; response: User[] }
294
+ }
295
+ }
296
+ }
215
297
  ```
216
298
 
217
- ### `forge.ready()` 方法
299
+ The backend Laravel package ([route-forge/route-forge-laravel](https://github.com/route-forge/route-forge-laravel)) also ships `php artisan route:forge:types`, which generates the same structure.
218
300
 
219
- auto-discovery + eager load 完成后 resolve,返回 `Promise<RouteForge>`(resolve 值为 forge 自身),适合 async/await 风格:
301
+ ## The `unassigned` level
220
302
 
221
- ```ts
222
- const forge = createRouteForge({ endpoint: '/_forge/routes' })
223
-
224
- // 无参模式:直接 await
225
- await forge.ready()
226
- // 路由数据已就绪,可安全调用 route() / hasRoute()
303
+ Routes the backend did not assign to any level live under a special `unassigned` level that the backend always injects into the summary's `levels`. The frontend treats it exactly like any other level — it lazy-loads `levels.unassigned.route.uri` over HTTP:
227
304
 
228
- // 回调模式:onFulfilled / onRejected
229
- forge.ready(
230
- (f) => { console.log('ready!', f) },
231
- (err) => { console.error(err) }
232
- )
233
-
234
- // 链式调用:ready 返回 forge 自身
235
- const bound = await forge.ready().then(f => f.use('admin'))
305
+ ```ts
306
+ await forge.load('unassigned')
307
+ const data = await forge.api('unassigned', 'some.route')
236
308
  ```
237
309
 
238
- ### `forge.use(level?, prefix?)` 层级绑定
310
+ ## Adapters
239
311
 
240
- `forge.use()` core 层唯一的 level 绑定入口,返回 `BoundForge` 对象:
312
+ | `adapter` value | Behavior |
313
+ |-----------------|----------|
314
+ | `'auto'` (default) | probes the host via dynamic `import('axios')`: reuses it when found (inheriting its interceptors / defaults), otherwise falls back to the built-in `builtin` implementation |
315
+ | `'axios'` | forces host axios; throws `AdapterNotFoundError` (RF_FE_005) when not installed |
316
+ | `'builtin'` | forces the built-in fetch implementation (zero dependencies, min+gzip < 3KB, axios-compatible interceptor behavior) |
317
+ | custom `Fetcher` | pass any object implementing `request(config): Promise<ResponseData>` for full control |
241
318
 
242
- ```ts
243
- // 绑定层级 — 自动触发 load,提供快捷方法
244
- const bound = forge.use('admin')
245
- bound('users.show', { user: 1 }) // 可直接调用(= bound.api())
246
- bound.route('users.show') // URL 生成
247
- bound.level // → 'admin'
248
- bound.levelLoaded // Promise<void>
249
-
250
- // 绑定层级 + 前缀 — 路由名自动拼接
251
- const bound = forge.use('admin', 'users')
252
- bound('show', { user: 1 }) // → forge.api('admin', 'users.show', ...)
253
-
254
- // BoundForge 独有方法
255
- await bound.onLevelLoaded() // 等待 level 加载完成
256
- const prefixed = bound.useRoutePrefix('posts') // 追加前缀
257
- ```
319
+ Bodies of type `FormData` / `Blob` / `ArrayBuffer` / `URLSearchParams` / `ReadableStream` skip JSON serialization automatically (plain `string` bodies pass through as well).
258
320
 
259
- ### IIFE 浏览器用法
321
+ ## IIFE browser usage
260
322
 
261
- 通过 `<script>` 标签引入后,`RouteForge` 全局可用:
323
+ With a `<script>` tag, the `RouteForge` global becomes available:
262
324
 
263
325
  ```html
264
- <script src="https://unpkg.com/@route-forge/core"></script>
326
+ <!-- production build (minified, ~19 KB / ~7 KB gzip) -->
327
+ <script src="https://unpkg.com/@route-forge/core/dist/route-forge.global.min.js"></script>
265
328
  <script>
266
- const forge = RouteForge.createRouteForge({
267
- endpoint: '/_forge/routes',
268
- })
269
-
270
- // 等待就绪后绑定层级
271
- forge.ready().then(function(f) {
329
+ const forge = RouteForge.createRouteForge({ endpoint: '/_forge/routes' })
330
+ forge.ready().then(function (f) {
272
331
  const admin = f.use('admin')
273
- return admin.onLevelLoaded()
274
- }).then(function(bound) {
275
- return bound('users.show', { user: 1 })
276
- }).then(function(data) {
332
+ return admin.onLevelLoaded().then(function () {
333
+ return admin('users.show', { user: 1 })
334
+ })
335
+ }).then(function (data) {
277
336
  console.log(data)
278
337
  })
279
338
  </script>
280
339
  ```
281
340
 
282
- ### Auto-discovery 守卫
283
-
284
- `route()` / `hasRoute()` 在 auto-discovery 未完成且无 explicit levels 时抛出
285
- `ForgeError (RF_FE_010)`, 防止在路由数据未就绪时返回错误结果。`api()` 不受影响(内部自动 await
286
- discovery)。
287
-
288
- ## 工具导出
341
+ > Always reference the IIFE artifact under `dist/`; the bare unpkg package name resolves to the CJS entry, which browsers cannot execute directly.
289
342
 
290
- `createRouteForge` 外,core 包还导出以下工具件,供高级场景按需使用:
343
+ ## Error reference
291
344
 
292
- | 导出 | 说明 |
293
- |-----------------------------|----------------------------------------------------------------------------------------|
294
- | `createInterceptorManager` | 创建拦截器管理器(`use`/`eject`/`clear`),供自定义 Fetcher 复用统一拦截器实现 |
295
- | `RouteCache` | 按层级隔离的路由缓存类(memory / sessionStorage / localStorage,TTL 过期),可独立使用 |
296
- | `LoadingTracker` | 加载状态跟踪器(引用计数 + 订阅),框架适配层可基于它实现全局加载指示 |
297
- | `resolveRouteName` | 前缀歧义异步消解(`prefix.suffix` 优先,回退后缀本身),`api()` 调用路径使用 |
298
- | `resolveRouteNameSync` | 前缀歧义同步消解(基于已加载缓存),`route()` / `url()` 调用路径使用 |
345
+ All errors extend `ForgeError` and carry a stable `code` field (the `ForgeErrorCode` literal union), so you can branch on `code` (with exhaustive `switch` checking):
299
346
 
300
- ## 错误参考
347
+ | Error class | code | Trigger |
348
+ |-------------|------|---------|
349
+ | `UnknownRouteError` | `RF_FE_001` | route name not found in the loaded level |
350
+ | `UnknownLevelError` | `RF_FE_002` | level not declared (frontend validation is always on) |
351
+ | `MissingRouteParamError` | `RF_FE_003` | required path parameter missing (no backend default); also thrown when a path parameter receives an object |
352
+ | `AdapterNotFoundError` | `RF_FE_005` | `adapter: 'axios'` but no usable host axios |
353
+ | `InvalidInterceptorReturnError` | `RF_FE_006` | a request interceptor did not return a RequestConfig object |
354
+ | `NetworkError` | `RF_FE_007` | network-layer failure (DNS, refused connection…); `cause` keeps the original error |
355
+ | `HTTPError` | `RF_FE_008` | non-2xx HTTP response; `context.status` holds the status code |
356
+ | `RequestAbortedError` | `RF_FE_009` | request cancelled via `abort()` / AbortSignal |
357
+ | `ForgeError` (guard) | `RF_FE_010` | `route()` / `hasRoute()` called before auto-discovery completed |
301
358
 
302
- 所有错误均为 `ForgeError` 子类,携带稳定的 `code` 字段,可按 `code` 分支处理:
303
-
304
- | 错误类 | code | 触发场景 |
305
- |-------------------------------|-------------|------------------------------------------------------|
306
- | `UnknownRouteError` | `RF_FE_001` | 路由名不存在于已加载层级中 |
307
- | `UnknownLevelError` | `RF_FE_002` | 层级未在 levels 声明(前端校验始终开启) |
308
- | `MissingRouteParamError` | `RF_FE_003` | 必填路径参数缺失(无后端默认值) |
309
- | `AdapterNotFoundError` | `RF_FE_005` | `adapter: 'axios'` 但宿主未安装/无有效 axios |
310
- | `InvalidInterceptorReturnError` | `RF_FE_006` | 请求拦截器未返回 RequestConfig 对象 |
311
- | `NetworkError` | `RF_FE_007` | 网络层失败(DNS、连接被拒等),`cause` 保留原始错误 |
312
- | `HTTPError` | `RF_FE_008` | HTTP 非 2xx,`context.status` 为状态码 |
313
- | `RequestAbortedError` | `RF_FE_009` | 请求被 `abort()` / AbortSignal 取消 |
314
- | `ForgeError`(守卫) | `RF_FE_010` | auto-discovery 未完成时调用 `route()`/`hasRoute()` |
315
-
316
- 错误对象结构:
359
+ Error object shape:
317
360
 
318
361
  ```ts
319
362
  {
320
- code: 'RF_FE_008', // 稳定错误码
321
- route?: string, // 关联路由名
322
- level?: string, // 关联层级
323
- context?: Record<string, unknown>, // 附加上下文(如 HTTP 状态码)
324
- cause?: unknown, // 原始底层错误
363
+ code: 'RF_FE_008', // stable error code
364
+ route?: string, // related route name
365
+ level?: string, // related level
366
+ context?: Record<string, unknown>, // extra context (HTTP status, url, method…)
367
+ cause?: unknown, // original underlying error
325
368
  }
326
369
  ```
327
370
 
328
- ## 文档
371
+ ## Utility exports
372
+
373
+ Besides `createRouteForge`, the core package exports these building blocks for advanced scenarios:
374
+
375
+ | Export | Description |
376
+ |--------|-------------|
377
+ | `createInterceptorManager` | creates an interceptor manager (`use`/`eject`/`clear`) so custom Fetchers can reuse the same interceptor implementation |
378
+ | `RouteCache` | per-level isolated route cache (memory / sessionStorage / localStorage, TTL expiry); usable standalone |
379
+ | `LoadingTracker` | loading-state tracker (reference counting + subscriptions) for building global loading indicators |
380
+ | `resolveRouteName` | async prefix-ambiguity resolution (prefer `prefix.suffix`, fall back to suffix); used by the `api()` path |
381
+ | `resolveRouteNameSync` | sync variant based on the loaded cache; used by the `route()` / `url()` path |
382
+
383
+ Type exports: `RouteForge` / `RouteForgeOptions` / `BoundForge` / `ApiCallParams` / `RequestConfig` / `ResponseData` / `ForgeRequest` / `Fetcher` / `RouteMeta` / `SummaryResponse` / `ForgeRouteMap` / `ForgeErrorCode` and more (full list in `dist/index.d.ts`).
384
+
385
+ ## FAQ
386
+
387
+ **`route()` / `hasRoute()` throw `RF_FE_010`?**
388
+ Auto-discovery hasn't completed. `await forge.ready()` first, or use the framework packages' `useForgeRoute`, which handles the loading state internally (returns `''` until ready).
389
+
390
+ **`ready()` rejected — what now?**
391
+ The summary endpoint is unreachable and no explicit `levels` were provided. Either fix endpoint connectivity, or pass explicit `levels` to gain the degradation path (falls back to the explicit configuration when the summary fails).
392
+
393
+ **Responses aren't unwrapped with `resp.data`?**
394
+ Unwrapping is response-interceptor behavior — register `(resp) => resp.data` yourself; by default `api()` resolves with the final value of the interceptor chain over the full `ResponseData`.
395
+
396
+ **Cache out of sync across browser tabs?**
397
+ Storage modes (sessionStorage / localStorage) automatically invalidate the in-memory mirror when another tab writes, via `storage` events; `memory` mode is per-tab by design.
398
+
399
+ ## Documentation
329
400
 
330
- - 仓库主页: https://github.com/route-forge/route-forge
331
- - 设计文档: https://github.com/route-forge/route-forge/blob/main/.docs/DESIGN.md
332
- - 规范: https://github.com/route-forge/route-forge/blob/main/.docs/SPEC.md
401
+ - Repository: <https://github.com/route-forge/route-forge>
402
+ - Design notes: <https://github.com/route-forge/route-forge/blob/main/.docs/DESIGN.md>
403
+ - Specification: <https://github.com/route-forge/route-forge/blob/main/.docs/SPEC.md>
404
+ - Backend package (Laravel): <https://github.com/route-forge/route-forge-laravel>
333
405
 
334
406
  ## License
335
407