@spfn/core 0.3.0-beta.11 → 0.3.0-beta.13
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 +474 -166
- package/dist/codegen/index.js.map +1 -1
- package/dist/contract/index.d.ts +1 -1
- package/dist/nextjs/index.d.ts +2 -2
- package/dist/nextjs/index.js +13 -13
- 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-yv6z_mK7.d.ts → types-QoyjaJWw.d.ts} +6 -0
- package/package.json +1 -1
- package/src/codegen/README.md +117 -22
- package/src/contract/README.md +7 -1
- package/src/nextjs/README.md +4 -0
- package/src/route/README.md +14 -0
|
@@ -252,6 +252,12 @@ interface SetCookie {
|
|
|
252
252
|
type RequestInterceptor = (url: string, init: RequestInit) => Promise<RequestInit> | RequestInit;
|
|
253
253
|
/**
|
|
254
254
|
* Response interceptor - called after fetch
|
|
255
|
+
*
|
|
256
|
+
* It runs outside the client's transport error handling, so throwing from it is a
|
|
257
|
+
* supported way to end the call: the error reaches the caller unchanged, which is what
|
|
258
|
+
* lets `redirect()` and `notFound()` navigate from one (issue #104). Returning
|
|
259
|
+
* `{ response, body }` replaces both for everything after it, error-status handling
|
|
260
|
+
* included.
|
|
255
261
|
*/
|
|
256
262
|
type ResponseInterceptor = (response: Response, body: any) => Promise<{
|
|
257
263
|
response: Response;
|
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.13",
|
|
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
|
@@ -619,6 +619,10 @@ export async function createUser(formData: FormData) {
|
|
|
619
619
|
`x-forwarded-for` — adding it back makes the IP spoofable. See *Header forwarding & client IP*.
|
|
620
620
|
- **Each `.headers()/.cookies()/.fetchOptions()/.onRequest()/.onResponse()` returns a new
|
|
621
621
|
builder.** Chain them in one expression; a dangling builder without `.call()` does nothing.
|
|
622
|
+
- **A client `onResponse` may throw, and the throw is the caller's.** It runs outside the
|
|
623
|
+
transport error handling, so `redirect('/login')` on a 401 navigates and a domain error
|
|
624
|
+
raised there is raised as itself — neither is relabelled an `ApiError` of type `'network'`.
|
|
625
|
+
Only a failed fetch (`0` / `'network'`) and an aborted one (`408` / `'timeout'`) are.
|
|
622
626
|
|
|
623
627
|
---
|
|
624
628
|
|
package/src/route/README.md
CHANGED
|
@@ -458,6 +458,20 @@ export const appRouter = defineRouter({ getRoot, getStatus })
|
|
|
458
458
|
|
|
459
459
|
`.packages()` also flattens any nested package routers the given routers themselves declared.
|
|
460
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
|
+
|
|
461
475
|
### `.use([...])` — router-level global middleware
|
|
462
476
|
|
|
463
477
|
Named middleware applied to **every** route in the router (and package routers), unless a
|