@spfn/core 0.3.0-beta.10 → 0.3.0-beta.12
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/dist/codegen/index.d.ts +64 -15
- package/dist/codegen/index.js +464 -167
- package/dist/codegen/index.js.map +1 -1
- package/dist/contract/index.d.ts +1 -1
- package/dist/nextjs/index.d.ts +36 -5
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +1 -1
- package/dist/ops/index.d.ts +1 -1
- package/dist/ops/index.js +24 -29
- package/dist/ops/index.js.map +1 -1
- package/dist/route/index.d.ts +2 -2
- package/dist/route/index.js +31 -27
- package/dist/route/index.js.map +1 -1
- package/dist/{router-DJdpwuB6.d.ts → router-9L3El7nO.d.ts} +37 -1
- package/dist/server/index.js +21 -26
- package/dist/server/index.js.map +1 -1
- package/dist/{types-9oszaJqp.d.ts → types-yv6z_mK7.d.ts} +122 -3
- package/docs/file-upload.md +3 -3
- package/package.json +1 -1
- package/src/codegen/README.md +117 -22
- package/src/contract/README.md +7 -1
- package/src/nextjs/README.md +43 -2
- package/src/route/README.md +20 -0
|
@@ -62,13 +62,112 @@ type InferRouteInput<TRoute> = TRoute extends RouteDef<infer TInput, any, any> ?
|
|
|
62
62
|
* ```
|
|
63
63
|
*/
|
|
64
64
|
type InferRouteOutput<TRoute> = TRoute extends RouteDef<any, any, infer TResponse> ? TResponse : never;
|
|
65
|
+
/**
|
|
66
|
+
* True only for `any`.
|
|
67
|
+
*
|
|
68
|
+
* `any` satisfies both branches of a conditional type, so a router whose routes are
|
|
69
|
+
* unresolved (`Router<any>`) would otherwise flatten into "every name exists" and
|
|
70
|
+
* accept a misspelled route name. A route record that is not known contributes no
|
|
71
|
+
* names instead.
|
|
72
|
+
*/
|
|
73
|
+
type IsAny<T> = 0 extends (1 & T) ? true : false;
|
|
74
|
+
/**
|
|
75
|
+
* True when `T` is a union of two or more members.
|
|
76
|
+
*/
|
|
77
|
+
type IsUnion<T, TCopy = T> = T extends unknown ? ([TCopy] extends [T] ? false : true) : false;
|
|
78
|
+
/**
|
|
79
|
+
* One leaf of a router tree: the name a route is registered under, its definition, and
|
|
80
|
+
* the path it was reached by.
|
|
81
|
+
*
|
|
82
|
+
* A route is identified by its own key whatever depth it sits at — `registerRoutes`
|
|
83
|
+
* mounts a nested route under that key, and the contract collector reports a collision
|
|
84
|
+
* on it. Nesting groups the source; it does not qualify the name. The trail is carried
|
|
85
|
+
* so that one name reached through two branches stays two leaves.
|
|
86
|
+
*/
|
|
87
|
+
interface RouteLeaf<TName extends PropertyKey, TDef, TTrail extends PropertyKey[]> {
|
|
88
|
+
name: TName;
|
|
89
|
+
def: TDef;
|
|
90
|
+
trail: TTrail;
|
|
91
|
+
}
|
|
92
|
+
/** Decrements the nesting budget; `PrevDepth[0]` is `never`, which ends the walk. */
|
|
93
|
+
type PrevDepth = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
94
|
+
/**
|
|
95
|
+
* How many levels of nesting are followed.
|
|
96
|
+
*
|
|
97
|
+
* A budget rather than open recursion: a router type that refers to itself would
|
|
98
|
+
* otherwise never terminate, and TypeScript would report an instantiation-depth error
|
|
99
|
+
* in whichever component happened to read a route type. A route below the budget is not
|
|
100
|
+
* visible to these types; ten levels is far past any hand-written tree.
|
|
101
|
+
*/
|
|
102
|
+
type MaxNesting = 10;
|
|
103
|
+
/**
|
|
104
|
+
* Collect every route in a route record as a leaf, descending into nested routers
|
|
105
|
+
* exactly as `registerRoutes` does.
|
|
106
|
+
*/
|
|
107
|
+
type RouteLeaves<TRoutes, TTrail extends PropertyKey[] = [], TDepth extends number = MaxNesting> = [
|
|
108
|
+
TDepth
|
|
109
|
+
] extends [never] ? never : IsAny<TRoutes> extends true ? never : {
|
|
110
|
+
[K in keyof TRoutes]-?: RouteLeafOf<K, Exclude<TRoutes[K], undefined>, [...TTrail, K], TDepth>;
|
|
111
|
+
}[keyof TRoutes];
|
|
112
|
+
/**
|
|
113
|
+
* A nested router contributes its own leaves; anything else is a leaf.
|
|
114
|
+
*
|
|
115
|
+
* `{ _routes: ... }` is the runtime's own test for a router (`isRouter`), and a
|
|
116
|
+
* `RouteDef` carries `handler`, `_input` and `_response` but never `_routes`, so a
|
|
117
|
+
* route cannot be mistaken for a router.
|
|
118
|
+
*/
|
|
119
|
+
type RouteLeafOf<TName extends PropertyKey, TValue, TTrail extends PropertyKey[], TDepth extends number> = TValue extends {
|
|
120
|
+
_routes: infer TNested;
|
|
121
|
+
} ? RouteLeaves<TNested, TTrail, PrevDepth[TDepth]> : RouteLeaf<TName, TValue, TTrail>;
|
|
122
|
+
/** Every name the tree declares. */
|
|
123
|
+
type LeafNames<TLeaves> = Extract<TLeaves extends RouteLeaf<infer TName, any, any> ? TName : never, PropertyKey>;
|
|
124
|
+
/**
|
|
125
|
+
* The leaves declaring exactly this name.
|
|
126
|
+
*
|
|
127
|
+
* Exactly, not assignably: a record typed `Record<string, RouteDef>` contributes the
|
|
128
|
+
* name `string`, which every literal name is assignable to, and a loose sibling must
|
|
129
|
+
* not make each of its siblings look doubly declared.
|
|
130
|
+
*/
|
|
131
|
+
type LeavesNamed<TLeaves, TName extends PropertyKey> = TLeaves extends RouteLeaf<infer TLeafName, any, any> ? [TLeafName] extends [TName] ? [TName] extends [TLeafName] ? TLeaves : never : never : never;
|
|
132
|
+
/** The definition a leaf carries. */
|
|
133
|
+
type LeafDef<TLeaves> = TLeaves extends RouteLeaf<any, infer TDef, any> ? TDef : never;
|
|
134
|
+
/** Where a leaf was found — one distinct trail per place the name is declared. */
|
|
135
|
+
type LeafTrail<TLeaves> = TLeaves extends RouteLeaf<any, any, infer TTrail> ? TTrail : never;
|
|
136
|
+
/**
|
|
137
|
+
* Flatten the leaves into one namespace of name → definition.
|
|
138
|
+
*
|
|
139
|
+
* A name that two branches both declare is left out rather than resolved to one of
|
|
140
|
+
* them: the tree gives it no single meaning, and picking a winner silently would hand
|
|
141
|
+
* a component another route's types. Naming it then fails the `K` constraint on
|
|
142
|
+
* `RouterOutput`/`RouterInput`, on the line that named it. Two places is counted by
|
|
143
|
+
* trail, so one slot holding a union of route definitions stays one route and still
|
|
144
|
+
* infers the union.
|
|
145
|
+
*
|
|
146
|
+
* The result is a fresh mapped type, so no key carries an optional or readonly modifier
|
|
147
|
+
* out of the source record — a route key that turned optional would change what
|
|
148
|
+
* `InferRouteInput` returns for it.
|
|
149
|
+
*/
|
|
150
|
+
type FlattenLeaves<TLeaves> = {
|
|
151
|
+
[TName in LeafNames<TLeaves> as UnambiguousName<TLeaves, TName>]: LeafDef<LeavesNamed<TLeaves, TName>>;
|
|
152
|
+
};
|
|
153
|
+
/** The name itself when one place declares it, and nothing when two do. */
|
|
154
|
+
type UnambiguousName<TLeaves, TName extends PropertyKey> = IsUnion<LeafTrail<LeavesNamed<TLeaves, TName>>> extends true ? never : TName;
|
|
65
155
|
/**
|
|
66
156
|
* Extract routes from Router type
|
|
67
|
-
*
|
|
157
|
+
*
|
|
158
|
+
* `Router<TRoutes>` holds its routes in `_routes`, and a value in that record is either
|
|
159
|
+
* a route or another router. Nested routers are flattened away, so every route appears
|
|
160
|
+
* here under its own name at one level — the same set of names the server registers.
|
|
161
|
+
*
|
|
162
|
+
* Exported so that the typed client (`Client`, in `./builder`) names a route the same
|
|
163
|
+
* way `RouterOutput` and `RouterInput` do; the two must not diverge, or a route would
|
|
164
|
+
* type in one place and not the other. It stays internal to the package: the
|
|
165
|
+
* `@spfn/core/nextjs` entry point exports a fixed list of type names, and this is not
|
|
166
|
+
* one of them.
|
|
68
167
|
*/
|
|
69
168
|
type ExtractRoutes<TRouter> = TRouter extends {
|
|
70
169
|
_routes: infer TRoutes;
|
|
71
|
-
} ? TRoutes : TRouter
|
|
170
|
+
} ? FlattenLeaves<RouteLeaves<TRoutes>> : FlattenLeaves<RouteLeaves<TRouter>>;
|
|
72
171
|
/**
|
|
73
172
|
* Extract output type for a specific route from router
|
|
74
173
|
*
|
|
@@ -88,6 +187,23 @@ type ExtractRoutes<TRouter> = TRouter extends {
|
|
|
88
187
|
* // Extract item type from paginated response
|
|
89
188
|
* type Example = RouterOutput<AppRouter, 'listExamples'>['items'][number];
|
|
90
189
|
* ```
|
|
190
|
+
*
|
|
191
|
+
* A route declared inside a nested router is named the same way. The server registers
|
|
192
|
+
* a nested route under its own key, so `K` is the flat set of route names in the tree
|
|
193
|
+
* and the depth the route was declared at never appears here:
|
|
194
|
+
*
|
|
195
|
+
* ```typescript
|
|
196
|
+
* const appRouter = defineRouter({
|
|
197
|
+
* getRoot,
|
|
198
|
+
* examples: defineRouter({ listExamples, createExample }),
|
|
199
|
+
* });
|
|
200
|
+
*
|
|
201
|
+
* type ListData = RouterOutput<typeof appRouter, 'listExamples'>;
|
|
202
|
+
* ```
|
|
203
|
+
*
|
|
204
|
+
* Two branches declaring the same name make that name ambiguous, and it is left out of
|
|
205
|
+
* `K` rather than resolved to one of them — naming it is a compile error here instead
|
|
206
|
+
* of the wrong route's types reaching a component.
|
|
91
207
|
*/
|
|
92
208
|
type RouterOutput<TRouter, K extends keyof ExtractRoutes<TRouter>> = InferRouteOutput<ExtractRoutes<TRouter>[K]>;
|
|
93
209
|
/**
|
|
@@ -106,6 +222,9 @@ type RouterOutput<TRouter, K extends keyof ExtractRoutes<TRouter>> = InferRouteO
|
|
|
106
222
|
* // ...
|
|
107
223
|
* }
|
|
108
224
|
* ```
|
|
225
|
+
*
|
|
226
|
+
* `K` is the flat set of route names, so a route declared inside a nested router is
|
|
227
|
+
* named by its own key here too — see {@link RouterOutput}.
|
|
109
228
|
*/
|
|
110
229
|
type RouterInput<TRouter, K extends keyof ExtractRoutes<TRouter>> = InferRouteInput<ExtractRoutes<TRouter>[K]>;
|
|
111
230
|
/**
|
|
@@ -247,4 +366,4 @@ interface CallOptions {
|
|
|
247
366
|
};
|
|
248
367
|
}
|
|
249
368
|
|
|
250
|
-
export type { ApiConfig as A, CallOptions as C, InferRouteInput as I, RequestInterceptor as R, SetCookie as S, ResponseInterceptor as a, InferRouteOutput as b, CookieOptions as c, RouterInput as d, RouterOutput as e, StructuredInput as f };
|
|
369
|
+
export type { ApiConfig as A, CallOptions as C, ExtractRoutes as E, InferRouteInput as I, RequestInterceptor as R, SetCookie as S, ResponseInterceptor as a, InferRouteOutput as b, CookieOptions as c, RouterInput as d, RouterOutput as e, StructuredInput as f };
|
package/docs/file-upload.md
CHANGED
|
@@ -229,8 +229,8 @@ export const uploadImage = route.post('/images')
|
|
|
229
229
|
|
|
230
230
|
### `@spfn/storage` (recommended)
|
|
231
231
|
|
|
232
|
-
SPFN ships provider-agnostic object storage — S3-compatible services (S3, R2,
|
|
233
|
-
|
|
232
|
+
SPFN ships provider-agnostic object storage — S3-compatible services (S3, R2, Wasabi,
|
|
233
|
+
SeaweedFS), Google Cloud Storage, and the local filesystem behind one interface. Prefer it
|
|
234
234
|
over calling a provider SDK directly: every object operation validates its key before it
|
|
235
235
|
reaches the provider (rejecting `..` segments, leading `/`, backslashes, control
|
|
236
236
|
characters and URLs), so a key built from user input cannot escape its prefix.
|
|
@@ -281,7 +281,7 @@ Three constraints decide whether this is safe:
|
|
|
281
281
|
| Constraint | Behaviour |
|
|
282
282
|
|---|---|
|
|
283
283
|
| `contentLength` (exact size) | Signed on **both** S3-compatible and GCS. A mismatched size fails. |
|
|
284
|
-
| `maxBytes` (upper bound) | Enforced on **GCS only**. A presigned PUT cannot sign a size range, so S3, R2
|
|
284
|
+
| `maxBytes` (upper bound) | Enforced on **GCS only**. A presigned PUT cannot sign a size range, so S3, R2 and Wasabi **ignore it silently**. |
|
|
285
285
|
| Local filesystem provider | Presigned upload is **not supported** — `getUploadUrl()` throws. Use the direct `upload()` path in local dev. |
|
|
286
286
|
|
|
287
287
|
A client can declare one size and send another, so a server-side check of a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spfn/core",
|
|
3
|
-
"version": "0.3.0-beta.
|
|
3
|
+
"version": "0.3.0-beta.12",
|
|
4
4
|
"description": "Full-stack TypeScript backend for Next.js: file-based typed routes, Drizzle entities and repositories, PostgreSQL transactions and a generated end-to-end client, in one fixed vertical slice per feature",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
package/src/codegen/README.md
CHANGED
|
@@ -127,7 +127,7 @@ call `generators['route-map'](config)`.
|
|
|
127
127
|
|
|
128
128
|
## Built-in: `@spfn/core:route-map`
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
Loads your router and emits a `routeName → {method, path}` map. This map is
|
|
131
131
|
what the **RPC proxy** (`createRpcProxy` in `@spfn/core/nextjs/server`) uses to turn
|
|
132
132
|
`api.getUser.call(...)` into an HTTP request — it needs `method` and `path` without
|
|
133
133
|
importing server code into the client bundle.
|
|
@@ -139,18 +139,91 @@ importing server code into the client bundle.
|
|
|
139
139
|
| `name` | `'@spfn/core:route-map'` | yes | — | Generator identifier (literal). |
|
|
140
140
|
| `routerPath` | `string` | yes | — | Router file, relative to project root. Throws at construction if missing. |
|
|
141
141
|
| `outputPath` | `string` | no | `'./src/generated/route-map.ts'` | Where the map is written (parent dirs auto-created). |
|
|
142
|
-
| `additionalRouteDirs` | `string[]` | no | `[]` |
|
|
142
|
+
| `additionalRouteDirs` | `string[]` | no | `[]` | **Deprecated and ignored** for collection; still added to `watchPatterns` as `${dir}/**/*.ts`. |
|
|
143
|
+
|
|
144
|
+
### What it reads
|
|
145
|
+
|
|
146
|
+
The router module is loaded with jiti (`appRouter` → `default` → `router`) and walked the
|
|
147
|
+
way `registerRoutes` walks it. How the router is *written* does not matter: a one-line
|
|
148
|
+
`defineRouter({ createUser })`, an aliased key `create: createUser`, and a second
|
|
149
|
+
`defineRouter(` in the same file all produce the same map.
|
|
150
|
+
|
|
151
|
+
- A **nested router** recurses to any depth and its routes land in the map **flat, under
|
|
152
|
+
their own keys** — the parent's key names the grouping, not the route, exactly as the
|
|
153
|
+
server registers them.
|
|
154
|
+
- `method` and `path` come from the `RouteDef`, not from a pattern over the source. A
|
|
155
|
+
route not registered in the router is absent, because there is nothing registering it.
|
|
156
|
+
- **`.packages()` routers are left out**, at every depth. A package publishes its own
|
|
157
|
+
route map and the app merges them (`{ ...routeMap, ...authRouteMap }`). Their *names*
|
|
158
|
+
are still read, because that merge is what a collision turns on — see below.
|
|
159
|
+
- A name that is not a JS identifier (`'get-user'`) is emitted quoted, and `__proto__` is
|
|
160
|
+
emitted as a computed key, so the generated file parses and the map keeps every route as
|
|
161
|
+
an own property.
|
|
162
|
+
- Imports are resolved by Node **and by the `compilerOptions.paths` of the nearest
|
|
163
|
+
`tsconfig.json` that declares them**, searched from the router file's own directory up to
|
|
164
|
+
the project root, `extends` chains included — so the `@/*` alias every spfn app has
|
|
165
|
+
resolves while the router loads, whether it lives in the root config (a Next.js app) or in
|
|
166
|
+
`src/server/tsconfig.json` (what `spfn init` scaffolds, and the config `spfn build`
|
|
167
|
+
compiles the server with). The search stops at the project root: a tsconfig above it
|
|
168
|
+
belongs to whatever contains the project. A config that declares no `paths` is passed over
|
|
169
|
+
rather than ending the search. `baseUrl` is honoured, so the scaffold's `"baseUrl": "../.."`
|
|
170
|
+
resolves against the project root as tsc resolves it. The failure names the specifier that
|
|
171
|
+
did not resolve.
|
|
172
|
+
- `NODE_ENV` is pinned to `production` when the shell left it unset, so the same router
|
|
173
|
+
does not load two ways depending on how codegen was invoked. A value the shell *did* set
|
|
174
|
+
is left alone.
|
|
143
175
|
|
|
144
|
-
###
|
|
176
|
+
### When it refuses
|
|
145
177
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
-
|
|
153
|
-
|
|
178
|
+
Unlike the old source parser, which dropped what it could not read, the generator throws
|
|
179
|
+
— logged always, and fatal on the `build` trigger and on `spfn codegen run`:
|
|
180
|
+
|
|
181
|
+
- The module will not load. An import that did not resolve names the specifier; anything
|
|
182
|
+
else names the cause and says a route module must be importable without side effects. It
|
|
183
|
+
never falls back to parsing.
|
|
184
|
+
- No router is found under `appRouter`, `default` or `router` (the message lists all three).
|
|
185
|
+
- The router file registers a route **conditionally** (`...(flag ? { admin } : {})`), which
|
|
186
|
+
would put the flag's value into the map and leave `api.admin.call()` 404ing wherever the
|
|
187
|
+
flag differs. This is the same guard `@spfn/core:contract` uses, and it reads **the router
|
|
188
|
+
the generator was pointed at** — the export the loader found it under, plus the routers
|
|
189
|
+
that export mounts by name from the same file, since a nested router's routes land in the
|
|
190
|
+
same flat map. A second `defineRouter(` the app router never reaches is not scanned: it
|
|
191
|
+
registers nothing in production. The source is read with TypeScript's parser, so a JSDoc
|
|
192
|
+
spelling `defineRouter({ ... })` in prose, a comment holding an old router and a brace
|
|
193
|
+
inside a string are not router source. Only a spread whose expression **reads a
|
|
194
|
+
condition** is refused — a ternary, `&&`, `||`, `??`. A call is allowed
|
|
195
|
+
(`...metadataRoutes(config, resource)`): loading yields exactly the routes it returns.
|
|
196
|
+
Past the guard, by construction: a conditional spread inside an imported module, a router
|
|
197
|
+
built by a factory, a condition hoisted to a variable before the spread, and a
|
|
198
|
+
`.packages()` list assembled conditionally. `NODE_ENV` pinning stands in for the guard on
|
|
199
|
+
those, but only when the shell left it unset: `spfn build` pins `production`, so what ships
|
|
200
|
+
is right, while `spfn dev` sets `development` and the watcher regenerates under it. The
|
|
201
|
+
generated header names the `NODE_ENV` the map was written under, so that difference is a
|
|
202
|
+
line in the diff rather than a route that vanishes at build.
|
|
203
|
+
- Two routes reach the same name (the message names both places).
|
|
204
|
+
- An **app route and a package route** reach the same name, where that package publishes a
|
|
205
|
+
route map. Both register at runtime, and the app's proxy merges
|
|
206
|
+
`{ ...routeMap, ...authRouteMap }` — so the package's route wins the name while the
|
|
207
|
+
generated types still describe the app's. Two *package* routers sharing a name is not
|
|
208
|
+
refused: which one wins is the app's merge order, which this generator neither sees nor
|
|
209
|
+
writes. Nor is a collision with a router that publishes **no** map — one declared with
|
|
210
|
+
`defineUnmappedRouter`, whose routes are reached by the URL something was handed rather
|
|
211
|
+
than by name: the ops surface (`createOpsRouter`), `@spfn/monitor`, `@spfn/cms` and the
|
|
212
|
+
tracking routes of `@spfn/notification`. Nothing of theirs merges over the app's map, so
|
|
213
|
+
an app route named after one of their routes overwrites nothing and must not fail the
|
|
214
|
+
build.
|
|
215
|
+
- A route's `method` is not an `HttpMethod`. `registerRoutes` lowercases the method before
|
|
216
|
+
registering, so `method: 'get'` serves fine and would emit a map that fails `tsc`.
|
|
217
|
+
|
|
218
|
+
A **missing router file** is still only a warning: the generator returns and writes nothing.
|
|
219
|
+
|
|
220
|
+
**Warned about and left out, not refused**: a route with no method or path (reachable by
|
|
221
|
+
registering a route before `.handler()` was called), and a router entry that is neither a
|
|
222
|
+
route nor a router. `registerRoutes` warns and skips both, so a map without them still names
|
|
223
|
+
every route the server answers — which is all a refusal would have been protecting.
|
|
224
|
+
|
|
225
|
+
What a *package* router carries is never refused — an entry the app developer cannot fix
|
|
226
|
+
is skipped, exactly as `registerRoutes` skips it.
|
|
154
227
|
|
|
155
228
|
### Generated output
|
|
156
229
|
|
|
@@ -199,10 +272,10 @@ generator's own surface.
|
|
|
199
272
|
|
|
200
273
|
| | `route-map` | `contract` |
|
|
201
274
|
|---|---|---|
|
|
202
|
-
| Reads the router by |
|
|
275
|
+
| Reads the router by | loading the module and walking `RouteDef`s | the same |
|
|
203
276
|
| Covers | every registered route | only routes carrying `.contract()` |
|
|
204
277
|
| `runOn` | `watch`, `manual`, `build` | `watch`, `build`, `manual` |
|
|
205
|
-
| Can fail a build |
|
|
278
|
+
| Can fail a build | **yes** — any refusal, on every trigger the CLI fails on | **yes**; a broken contract only on the `build` trigger |
|
|
206
279
|
|
|
207
280
|
Loading rather than parsing is what makes the contract correct: real routes build schemas from
|
|
208
281
|
imported values (`EmailSchema`, `FileSchema()`, constants) that a source parser cannot resolve. It
|
|
@@ -216,8 +289,10 @@ costs a module import and no infrastructure — `@spfn/auth`'s 43 routes load in
|
|
|
216
289
|
- No router export is found under the configured or default names.
|
|
217
290
|
- Two contracted routes share a name, or a contracted route has no method, path, `since` or
|
|
218
291
|
`response`.
|
|
219
|
-
-
|
|
220
|
-
the contract describe whichever way the
|
|
292
|
+
- The router the generator was pointed at contains a conditional spread
|
|
293
|
+
(`...(flag ? { route } : {})`), which would make the contract describe whichever way the
|
|
294
|
+
generator happened to run. Same guard as `route-map` — see its bullet for exactly what is
|
|
295
|
+
scanned and what is allowed.
|
|
221
296
|
- On the `build` trigger only: the contract breaks the newest released snapshot.
|
|
222
297
|
|
|
223
298
|
`spfn dev` generates but never refuses — being unable to hold a half-finished route mid-edit would
|
|
@@ -232,7 +307,7 @@ Provided by the `spfn` CLI (`@spfn/cli`), not by `@spfn/core` itself:
|
|
|
232
307
|
```bash
|
|
233
308
|
spfn codegen init # scaffold a .spfnrc.ts
|
|
234
309
|
spfn codegen list # list resolved generators + their watch patterns (alias: ls)
|
|
235
|
-
spfn codegen run # run all generators once (CodegenOrchestrator.generateAll, trigger 'manual')
|
|
310
|
+
spfn codegen run # run all generators once (CodegenOrchestrator.generateAll, trigger 'manual'; a failure exits 1)
|
|
236
311
|
spfn dev # dev server + codegen in watch mode
|
|
237
312
|
spfn build # runs codegen once before building (trigger 'build'; a failure exits 1)
|
|
238
313
|
|
|
@@ -245,6 +320,11 @@ spfn contract list # released snapshots (alias: ls)
|
|
|
245
320
|
(The `spfn init` project scaffold writes a `.spfnrc.ts` preconfigured with the route-map
|
|
246
321
|
generator and adds a `"codegen": "spfn codegen run"` npm script.)
|
|
247
322
|
|
|
323
|
+
`spfn codegen run` **exits 1 when a generator refuses**, and stops at the one that refused
|
|
324
|
+
— the run that just failed to regenerate a map must not look like the run that did. `spfn
|
|
325
|
+
dev` is unaffected: watch mode logs the failure and keeps going, because being unable to
|
|
326
|
+
hold a half-finished route mid-edit would make the feature unusable.
|
|
327
|
+
|
|
248
328
|
---
|
|
249
329
|
|
|
250
330
|
## Programmatic usage
|
|
@@ -405,11 +485,26 @@ regeneration when it's absent (build/manual passes don't set it).
|
|
|
405
485
|
wrong (or no) `method`/`path`. `spfn build` runs codegen first; if you build by other
|
|
406
486
|
means, run `spfn codegen run` yourself. Treat the file as generated (it's marked
|
|
407
487
|
`DO NOT EDIT`).
|
|
408
|
-
- **route-map only matches a specific route syntax.** Routes must be
|
|
409
|
-
`export const x = route.<method>('<literal path>')`. Dynamic paths, methods other than
|
|
410
|
-
get/post/put/patch/delete, or routes not listed in `defineRouter({...})` won't appear.
|
|
411
488
|
- **A route must be in `defineRouter({...})` to be emitted.** Defining `export const foo =
|
|
412
|
-
route.get(...)` but not registering `foo` in the router drops it from the map
|
|
489
|
+
route.get(...)` but not registering `foo` in the router drops it from the map — the
|
|
490
|
+
generator walks the router, so an unregistered route does not exist as far as it is
|
|
491
|
+
concerned. How the registration is *spelled* no longer matters.
|
|
492
|
+
- **route-map loads your router, so route modules must import cleanly.** Reading a
|
|
493
|
+
required environment value at module scope now fails `spfn build` and `spfn codegen run`,
|
|
494
|
+
not just `spfn dev`. Move that read inside the handler. Top-level `await` in a route or
|
|
495
|
+
router module does not load either — jiti transforms to CJS — the same limit
|
|
496
|
+
`@spfn/core:contract` has always had.
|
|
497
|
+
- **A path alias resolves only if a tsconfig between the router and the project root maps
|
|
498
|
+
it.** Both loading generators read `compilerOptions.paths` from the nearest such
|
|
499
|
+
`tsconfig.json` (following `extends`) and hand it to jiti, so `@/server/routes/users`
|
|
500
|
+
resolves from the root config or from `src/server/tsconfig.json`. A mapping that lives
|
|
501
|
+
*above* the project root — a monorepo's solution-style config — is not read; a `paths`
|
|
502
|
+
entry whose `*` is not a trailing `/*` is skipped (jiti matches a prefix, not a template);
|
|
503
|
+
and a `baseUrl` with no `paths` resolves nothing (an alias map cannot say "try this
|
|
504
|
+
directory for every bare specifier"). Several targets for one pattern are supported: the
|
|
505
|
+
first that exists on disk is used, as tsc and tsup use it. Each skip is warned about, an
|
|
506
|
+
`extends` target TypeScript could not read is warned about instead of silently dropping
|
|
507
|
+
its `paths`, and an import that then fails to resolve names the specifier.
|
|
413
508
|
- **Custom generators must default-export a factory.** `createGeneratorsFromConfig` calls
|
|
414
509
|
`module.default()` (a zero-arg function) for `{ path }` entries. Exporting the Generator
|
|
415
510
|
object directly, or a factory that needs args, won't load.
|
|
@@ -505,12 +600,12 @@ interface RouteMapGeneratorConfig
|
|
|
505
600
|
name: '@spfn/core:route-map';
|
|
506
601
|
routerPath: string;
|
|
507
602
|
outputPath?: string; // default './src/generated/route-map.ts'
|
|
508
|
-
additionalRouteDirs?: string[];
|
|
603
|
+
additionalRouteDirs?: string[]; // deprecated: watched, never collected from
|
|
509
604
|
}
|
|
510
605
|
```
|
|
511
606
|
|
|
512
607
|
## Related
|
|
513
608
|
|
|
514
|
-
- [@spfn/core/route](../route/README.md) — `route.*` / `defineRouter` (the
|
|
609
|
+
- [@spfn/core/route](../route/README.md) — `route.*` / `defineRouter` (the router loaded and walked)
|
|
515
610
|
- [@spfn/core/nextjs](../nextjs/README.md) — RPC proxy (`createRpcProxy`) that consumes `routeMap`
|
|
516
611
|
- [@spfn/core/env](../env/README.md) — environment configuration
|
package/src/contract/README.md
CHANGED
|
@@ -285,7 +285,13 @@ JSON body and upload the file through its own uncontracted route.
|
|
|
285
285
|
generator refuses a `defineRouter({...})` containing a computed spread (`...(flag ? {x} : {})`);
|
|
286
286
|
a spread of a plain identifier (`...baseRoutes`) is fine.
|
|
287
287
|
- **`NODE_ENV` is pinned when unset.** The generator sets it to `production` before loading, so a
|
|
288
|
-
schema that reads the environment cannot make the contract depend on the shell it ran in.
|
|
288
|
+
schema that reads the environment cannot make the contract depend on the shell it ran in. The pin
|
|
289
|
+
is process-wide and shared with `@spfn/core:route-map`, so both generators in one run read the
|
|
290
|
+
same environment whichever `.spfnrc.ts` lists first.
|
|
291
|
+
- **Path aliases come from the project root's `tsconfig.json`.** `compilerOptions.paths` is read
|
|
292
|
+
(`extends` followed) and handed to jiti, so a router importing `@/server/routes/users` loads. A
|
|
293
|
+
mapping held in any other tsconfig is not read, and the failure names the specifier that did not
|
|
294
|
+
resolve.
|
|
289
295
|
- **A failed build still rewrites `current.json`.** That is deliberate — the regenerated file is
|
|
290
296
|
what the gate compared, so `git diff` shows exactly what broke.
|
|
291
297
|
- **Constraint changes count as type changes.** Narrowing `maxLength`, an `enum` or a `format` is
|
package/src/nextjs/README.md
CHANGED
|
@@ -122,8 +122,28 @@ const created = await api.createUser.call({ body: { name: 'A' } }); // POST /api
|
|
|
122
122
|
function createApi<TRouter extends Router<any>>(config?: ApiConfig): Client<TRouter>;
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
-
Returns a `Proxy`: every property access (`api.getUser
|
|
126
|
-
|
|
125
|
+
Returns a `Proxy`: every property access (`api.getUser`) yields a `RouteClient` builder.
|
|
126
|
+
Calls are made via `.call(input)`.
|
|
127
|
+
|
|
128
|
+
The client is one **flat** namespace, holding exactly the names the server registers. A
|
|
129
|
+
route declared inside a nested `defineRouter` is reached by its own key — `registerRoutes`
|
|
130
|
+
mounts it under that key, the proxy resolves that name against the route map, and
|
|
131
|
+
`RouterOutput` takes the same name:
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
const appRouter = defineRouter({
|
|
135
|
+
getRoot,
|
|
136
|
+
users: defineRouter({ getUser }),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await api.getUser.call({ params: { id: '123' } }); // ✅ GET /api/rpc/getUser
|
|
140
|
+
await api.users.getUser.call({ ... }); // ❌ compile error — `users` is a group
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
There is no dot notation: a property access never yields another proxy, so a group key
|
|
144
|
+
names nothing on either side. A name two branches both declare is left out of the client
|
|
145
|
+
rather than resolved to one of them, and `.packages()` routes stay out on purpose — call
|
|
146
|
+
them through the package's own client (`authApi`, `cmsApi`).
|
|
127
147
|
|
|
128
148
|
### Structured input
|
|
129
149
|
|
|
@@ -452,6 +472,27 @@ type BodyInput = RouterInput<AppRouter, 'createExample'>['body'];
|
|
|
452
472
|
`InferRouteOutput` take a single `RouteDef`. All inference is compile-time only (zero runtime
|
|
453
473
|
cost).
|
|
454
474
|
|
|
475
|
+
The route-name key is the **flat** set of names in the router tree. A route declared inside a
|
|
476
|
+
nested `defineRouter` is named by its own key here, exactly as the server registers it — the
|
|
477
|
+
group key is not part of the name and does not appear:
|
|
478
|
+
|
|
479
|
+
```typescript
|
|
480
|
+
const appRouter = defineRouter({
|
|
481
|
+
getRoot,
|
|
482
|
+
examples: defineRouter({ listExamples, createExample }),
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
type ListData = RouterOutput<typeof appRouter, 'listExamples'>; // not 'examples.listExamples'
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
A name two branches both declare is left out of that set rather than resolved to one of them,
|
|
489
|
+
so naming it is a compile error where it is named. Routes mounted with `.packages()` stay out
|
|
490
|
+
of the set on purpose — call them through the package's own client.
|
|
491
|
+
|
|
492
|
+
`Client<AppRouter>` — what `createApi` returns — is built from the same flat set, so a route
|
|
493
|
+
is named identically whether you call it (`api.listExamples`) or take its type
|
|
494
|
+
(`RouterOutput<AppRouter, 'listExamples'>`).
|
|
495
|
+
|
|
455
496
|
---
|
|
456
497
|
|
|
457
498
|
## Error handling
|
package/src/route/README.md
CHANGED
|
@@ -435,6 +435,12 @@ export const appRouter = defineRouter({ ...userRoutes, ...postRoutes });
|
|
|
435
435
|
export type AppRouter = typeof appRouter;
|
|
436
436
|
```
|
|
437
437
|
|
|
438
|
+
Nesting groups the source, not the name: a nested route is registered — and typed, through
|
|
439
|
+
`RouterOutput`/`RouterInput` and the `createApi` client — under its own key (`get`, `list`),
|
|
440
|
+
never under `users.get`. `api.get.call({...})` is the call; `api.users` is not a route and
|
|
441
|
+
does not compile. Two branches declaring one name is therefore a collision, which the contract
|
|
442
|
+
collector refuses and the client types leave out of the namespace.
|
|
443
|
+
|
|
438
444
|
### `.packages([...])` — mount package routers
|
|
439
445
|
|
|
440
446
|
Attach routers from SPFN packages (`@spfn/auth`, `@spfn/cms`, …). Package routes **are**
|
|
@@ -452,6 +458,20 @@ export const appRouter = defineRouter({ getRoot, getStatus })
|
|
|
452
458
|
|
|
453
459
|
`.packages()` also flattens any nested package routers the given routers themselves declared.
|
|
454
460
|
|
|
461
|
+
**A name a package route already uses is not available to an app route.** Both register, at
|
|
462
|
+
their own paths, and the app's proxy merges the two maps as `{ ...routeMap, ...authRouteMap }`
|
|
463
|
+
— so the package entry wins the name while the generated types still describe the app's
|
|
464
|
+
route. `spfn codegen run` refuses rather than generating that, naming both sides.
|
|
465
|
+
|
|
466
|
+
A package that publishes **no** route map is the exception, and declares itself one with
|
|
467
|
+
`defineUnmappedRouter`: its routes are reached by the URL something was handed rather than by
|
|
468
|
+
name, so nothing merges over the app's map and an app route may share a name with one of them
|
|
469
|
+
freely. `createOpsRouter` builds the ops surface that way — `spfn ops` invokes a command over
|
|
470
|
+
its URL — as do `@spfn/monitor`, `@spfn/cms` and the tracking routes of `@spfn/notification`.
|
|
471
|
+
`defineRouter` is the default because the obligation runs the other way: a package that does
|
|
472
|
+
publish a map must not declare itself unmapped, or a collision that really would overwrite an
|
|
473
|
+
app route generates silently.
|
|
474
|
+
|
|
455
475
|
### `.use([...])` — router-level global middleware
|
|
456
476
|
|
|
457
477
|
Named middleware applied to **every** route in the router (and package routers), unless a
|