@route-forge/core 2.1.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 +282 -210
- package/README_zh.md +408 -0
- package/dist/codegen.cjs +2 -26
- package/dist/codegen.cjs.map +1 -1
- package/dist/codegen.d.cts +2 -9
- package/dist/codegen.d.ts +2 -9
- package/dist/codegen.js +2 -26
- package/dist/codegen.js.map +1 -1
- package/dist/index.cjs +100 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +12 -4
- package/dist/index.d.ts +12 -4
- package/dist/index.js +100 -47
- package/dist/index.js.map +1 -1
- package/dist/route-forge.global.js +100 -47
- package/dist/route-forge.global.js.map +1 -1
- package/dist/route-forge.global.min.js +2 -2
- package/dist/{types-C51rXaiY.d.cts → types-CDKE8rw-.d.cts} +41 -22
- package/dist/{types-C51rXaiY.d.ts → types-CDKE8rw-.d.ts} +41 -22
- package/package.json +20 -3
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
|
-
#
|
|
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
|
-
//
|
|
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
|
-
//
|
|
26
|
-
const url = forge.route('public', 'login.show')
|
|
27
|
-
|
|
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
|
-
|
|
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
|
|
37
|
-
const allRoutes = forge.getRoutes() // 全部层级
|
|
62
|
+
```ts
|
|
63
|
+
const forge = createRouteForge({ endpoint: '/_forge/routes' })
|
|
38
64
|
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
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.
|
|
46
|
-
forge.invalidate(['admin', 'manage']) // 批量失效多个层级
|
|
47
|
-
forge.invalidate() // 失效全部
|
|
80
|
+
// async/await style
|
|
81
|
+
await forge.ready()
|
|
48
82
|
```
|
|
49
83
|
|
|
50
|
-
|
|
84
|
+
The three loading phases and how to track them:
|
|
51
85
|
|
|
52
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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' }, // →
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
//
|
|
104
|
-
forge.
|
|
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
|
-
|
|
194
|
+
> Every `use()` call returns a fresh `BoundForge` (not cached); `forge.use()` without arguments returns the forge itself.
|
|
108
195
|
|
|
109
|
-
|
|
196
|
+
## Request cancellation
|
|
110
197
|
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
>
|
|
167
|
-
>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
277
|
+
Two ways to define it:
|
|
200
278
|
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
+
## The `unassigned` level
|
|
220
302
|
|
|
221
|
-
|
|
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
|
-
|
|
229
|
-
forge.
|
|
230
|
-
|
|
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
|
-
|
|
310
|
+
## Adapters
|
|
239
311
|
|
|
240
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
321
|
+
## IIFE browser usage
|
|
260
322
|
|
|
261
|
-
|
|
323
|
+
With a `<script>` tag, the `RouteForge` global becomes available:
|
|
262
324
|
|
|
263
325
|
```html
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>,
|
|
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
|
-
-
|
|
331
|
-
-
|
|
332
|
-
-
|
|
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
|
|