@spfn/core 0.3.0-beta.11 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spfn/core",
3
- "version": "0.3.0-beta.11",
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": {
@@ -127,7 +127,7 @@ call `generators['route-map'](config)`.
127
127
 
128
128
  ## Built-in: `@spfn/core:route-map`
129
129
 
130
- Parses your router + route files and emits a `routeName → {method, path}` map. This map is
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 | `[]` | Extra route dirs to watch (each becomes `${dir}/**/*.ts`). |
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
- ### What it parses
176
+ ### When it refuses
145
177
 
146
- - **Router file** (`routerPath`): relative `import { ... } from './...'` statements, and the
147
- route names listed inside the **first** `defineRouter({ ... })` call (top-level
148
- identifiers, comments stripped).
149
- - **Route files**: only the exact pattern
150
- `export const <name> = route.<get|post|put|patch|delete>('<path>')…` (single/double/back
151
- quotes). The leading `route.<method>('<path>')` call is what's matched.
152
- - Only routes whose `name` also appears in `defineRouter({...})` end up in the output
153
- (declared-but-unregistered routes are dropped).
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 | parsing the source | **loading the module** and walking `RouteDef`s |
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 | no | **yes**, on the `build` trigger only |
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
- - `defineRouter({...})` contains a computed spread (`...(flag ? { route } : {})`), which would make
220
- the contract describe whichever way the generator happened to run.
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 source parsed)
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
@@ -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
@@ -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