@nifrajs/cli 2.1.0 → 2.3.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.
Files changed (126) hide show
  1. package/README.md +25 -25
  2. package/dist/assure.d.ts +2 -2
  3. package/dist/assure.d.ts.map +1 -1
  4. package/dist/assure.js +17 -10
  5. package/dist/assure.js.map +1 -1
  6. package/dist/capabilities-tool.d.ts +7 -0
  7. package/dist/capabilities-tool.d.ts.map +1 -1
  8. package/dist/capabilities-tool.js +68 -16
  9. package/dist/capabilities-tool.js.map +1 -1
  10. package/dist/check.d.ts +64 -22
  11. package/dist/check.d.ts.map +1 -1
  12. package/dist/check.js +400 -83
  13. package/dist/check.js.map +1 -1
  14. package/dist/cli.d.ts +37 -0
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +291 -53
  17. package/dist/cli.js.map +1 -1
  18. package/dist/diff-tool.d.ts +3 -3
  19. package/dist/diff-tool.js +7 -7
  20. package/dist/docs-search.d.ts +3 -3
  21. package/dist/docs-search.js +6 -6
  22. package/dist/doctor.d.ts +8 -4
  23. package/dist/doctor.d.ts.map +1 -1
  24. package/dist/doctor.js +74 -14
  25. package/dist/doctor.js.map +1 -1
  26. package/dist/examples.d.ts +5 -5
  27. package/dist/examples.js +5 -5
  28. package/dist/init-agents.d.ts +14 -14
  29. package/dist/init-agents.js +14 -14
  30. package/dist/internal/typescript-import.d.ts +24 -0
  31. package/dist/internal/typescript-import.d.ts.map +1 -0
  32. package/dist/internal/typescript-import.js +32 -0
  33. package/dist/internal/typescript-import.js.map +1 -0
  34. package/dist/introspect.d.ts +12 -12
  35. package/dist/introspect.js +24 -24
  36. package/dist/levels-tool.d.ts +8 -8
  37. package/dist/levels-tool.d.ts.map +1 -1
  38. package/dist/levels-tool.js +33 -30
  39. package/dist/levels-tool.js.map +1 -1
  40. package/dist/load.d.ts +17 -6
  41. package/dist/load.d.ts.map +1 -1
  42. package/dist/load.js +45 -7
  43. package/dist/load.js.map +1 -1
  44. package/dist/manifest-tool.d.ts +1 -1
  45. package/dist/manifest-tool.d.ts.map +1 -1
  46. package/dist/manifest-tool.js +4 -2
  47. package/dist/manifest-tool.js.map +1 -1
  48. package/dist/mcp-docs-tools.d.ts +1 -1
  49. package/dist/mcp-docs-tools.d.ts.map +1 -1
  50. package/dist/mcp-docs-tools.js +14 -10
  51. package/dist/mcp-docs-tools.js.map +1 -1
  52. package/dist/mcp-http.d.ts +5 -5
  53. package/dist/mcp-http.js +7 -7
  54. package/dist/mcp-protocol.d.ts +2 -2
  55. package/dist/mcp-protocol.js +2 -2
  56. package/dist/mcp-render.d.ts +3 -3
  57. package/dist/mcp-render.js +15 -7
  58. package/dist/mcp-render.js.map +1 -1
  59. package/dist/mcp-run.d.ts +2 -2
  60. package/dist/mcp-run.js +2 -2
  61. package/dist/mcp.d.ts +14 -14
  62. package/dist/mcp.d.ts.map +1 -1
  63. package/dist/mcp.js +119 -47
  64. package/dist/mcp.js.map +1 -1
  65. package/dist/openapi-tool.d.ts +1 -1
  66. package/dist/openapi-tool.js +2 -2
  67. package/dist/pipeline-guard.d.ts +83 -0
  68. package/dist/pipeline-guard.d.ts.map +1 -0
  69. package/dist/pipeline-guard.js +136 -0
  70. package/dist/pipeline-guard.js.map +1 -0
  71. package/dist/port.d.ts +9 -9
  72. package/dist/port.js +27 -27
  73. package/dist/recipes/1.8.0.d.ts +2 -2
  74. package/dist/recipes/1.8.0.js +5 -5
  75. package/dist/recipes/index.d.ts +2 -2
  76. package/dist/recipes/index.js +1 -1
  77. package/dist/scaffold.d.ts +3 -3
  78. package/dist/scaffold.js +8 -8
  79. package/dist/sync-routes.d.ts +28 -0
  80. package/dist/sync-routes.d.ts.map +1 -0
  81. package/dist/sync-routes.js +55 -0
  82. package/dist/sync-routes.js.map +1 -0
  83. package/dist/types-search.d.ts +6 -6
  84. package/dist/types-search.d.ts.map +1 -1
  85. package/dist/types-search.js +53 -8
  86. package/dist/types-search.js.map +1 -1
  87. package/dist/upgrade.d.ts +7 -7
  88. package/dist/upgrade.js +12 -12
  89. package/dist/verification.d.ts +51 -0
  90. package/dist/verification.d.ts.map +1 -0
  91. package/dist/verification.js +80 -0
  92. package/dist/verification.js.map +1 -0
  93. package/docs/examples.json +178 -88
  94. package/docs/llms-full.txt +1766 -766
  95. package/docs/types.json +2171 -2807
  96. package/package.json +22 -11
  97. package/src/assure.ts +17 -18
  98. package/src/capabilities-tool.ts +82 -16
  99. package/src/check.ts +493 -100
  100. package/src/cli.ts +306 -54
  101. package/src/diff-tool.ts +8 -8
  102. package/src/docs-search.ts +6 -6
  103. package/src/doctor.ts +81 -16
  104. package/src/examples.ts +7 -7
  105. package/src/init-agents.ts +17 -17
  106. package/src/internal/typescript-import.ts +33 -0
  107. package/src/introspect.ts +28 -28
  108. package/src/levels-tool.ts +37 -31
  109. package/src/load.ts +65 -12
  110. package/src/manifest-tool.ts +4 -2
  111. package/src/mcp-docs-tools.ts +20 -10
  112. package/src/mcp-http.ts +7 -7
  113. package/src/mcp-protocol.ts +2 -2
  114. package/src/mcp-render.ts +15 -7
  115. package/src/mcp-run.ts +2 -2
  116. package/src/mcp.ts +127 -47
  117. package/src/openapi-tool.ts +2 -2
  118. package/src/pipeline-guard.ts +186 -0
  119. package/src/port.ts +31 -31
  120. package/src/recipes/1.8.0.ts +5 -5
  121. package/src/recipes/index.ts +2 -2
  122. package/src/scaffold.ts +8 -8
  123. package/src/sync-routes.ts +69 -0
  124. package/src/types-search.ts +57 -8
  125. package/src/upgrade.ts +12 -12
  126. package/src/verification.ts +123 -0
package/src/cli.ts CHANGED
@@ -1,22 +1,29 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * `nifra` — the zero-config CLI for a nifra app. Reads `framework.ts` + `backend.ts` + `routes/` from
3
+ * `nifra` - the zero-config CLI for a nifra app. Reads `framework.ts` + `backend.ts` + `routes/` from
4
4
  * the project root (see {@link loadApp}) and wires the right `@nifrajs/web` entrypoint:
5
5
  *
6
- * nifra dev true-HMR dev server (Vite middleware + nifra SSR) — @nifrajs/web/vite
7
- * nifra build emit a complete target-specific deploy directory — @nifrajs/web/build
8
- * nifra start run the default Bun build — dist/server.js
6
+ * nifra dev true-HMR dev server (Vite middleware + nifra SSR) - @nifrajs/web/vite
7
+ * nifra build emit a complete target-specific deploy directory - @nifrajs/web/build
8
+ * nifra start run the default Bun build - dist/server.js
9
9
  *
10
10
  * Bun-only (it runs the framework's TS + Bun plugins directly). The *output* runs anywhere.
11
11
  */
12
12
  import { existsSync } from "node:fs"
13
13
  import { resolve } from "node:path"
14
14
  import { inProcessClient } from "@nifrajs/client"
15
- import { createWebApp, DEFAULT_DEV_PORT, type RenderAdapter } from "@nifrajs/web"
15
+ import {
16
+ createWebApp,
17
+ DEFAULT_DEV_PORT,
18
+ type RenderAdapter,
19
+ SERVER_FN_MODULE,
20
+ SERVER_ONLY_MODULE,
21
+ } from "@nifrajs/web"
16
22
  import { discoverRoutes } from "@nifrajs/web/fs"
17
23
  import type { BunPlugin } from "bun"
18
24
  import { describeProject, describeRoutes } from "./introspect.ts"
19
- import { type LoadedApp, loadApp, resolvePlugins } from "./load.ts"
25
+ import { type LoadedApp, loadApp } from "./load.ts"
26
+ import { chooseBuildPipeline } from "./pipeline-guard.ts"
20
27
 
21
28
  export interface Flags {
22
29
  readonly port: number
@@ -26,13 +33,26 @@ export interface Flags {
26
33
  readonly target: string
27
34
  /** `nifra build --report`: print a per-chunk size + gzip table after the build. */
28
35
  readonly report: boolean
36
+ /** `nifra build --vite`: force the client + server through Vite/Rollup. Without a flag the pipeline is
37
+ * chosen per app (`chooseBuildPipeline`) - Bun unless the app's only transforms are `vitePlugins`. */
38
+ readonly vite: boolean
39
+ /** `nifra dev --bun`: run the Bun-pipeline dev server (Bun.serve native HMR - including React Fast
40
+ * Refresh with state preserved - and no Vite in the process). Default stays Vite for its plugin
41
+ * ecosystem; `--bun` trades that for one bundler across dev and prod. Refuses CSS-Modules apps.
42
+ *
43
+ * `nifra build --bun`: force the Bun build. Refuses when that would drop the app's `vitePlugins`. */
44
+ readonly bun: boolean
29
45
  }
30
46
 
31
- const HELP = `nifra — zero-config dev/build/start for a nifra app
47
+ const HELP = `nifra - zero-config dev/build/start for a nifra app
32
48
 
33
49
  Usage:
34
50
  nifra dev [--port <n>] [--poll] Start the true-HMR dev server (Vite). Default port ${DEFAULT_DEV_PORT}.
35
- nifra build [--out <dir>] [--report] Emit a complete Bun deploy directory by default.
51
+ [--bun] Run the BUN pipeline instead: Bun.serve native HMR (React Fast
52
+ Refresh included, state preserved), no Vite in the process - one
53
+ bundler across dev and prod. Plain CSS/Tailwind work; refuses an
54
+ app using *.module.css (Bun's dev bundler can't transform it).
55
+ nifra build [--out <dir>] [--report] Emit a complete deploy directory.
36
56
  [--target <t>] Target a FULL deploy dir for <t>:
37
57
  bun | node | deno | cf-pages | vercel | static. Packages
38
58
  buildClient + buildServer (+ prerender for static) so an app
@@ -40,6 +60,11 @@ Usage:
40
60
  _routes.json. The server entry is generated from your
41
61
  framework.ts (adapter) + backend.ts + routes/. --report prints
42
62
  a per-chunk size + gzip table (biggest first).
63
+ [--vite | --bun] Force the bundler. Without a flag it follows your config: Bun
64
+ (faster, Bun-native) unless your ONLY transforms are
65
+ \`vitePlugins\`, which the Bun build cannot run - then Vite,
66
+ and it says so. --bun is refused in exactly that case rather
67
+ than dropping the transforms silently.
43
68
  nifra start [--port <n>] [--out <dir>] Run the Bun build at <out>/server.js. Default port ${DEFAULT_DEV_PORT}.
44
69
  nifra context Print this project's route INDEX (API + page routes) + conventions
45
70
  for an AI agent's prompt. Per-route schemas: nifra mcp's
@@ -48,6 +73,11 @@ Usage:
48
73
  routes (routes/) + the in-process backend's API routes,
49
74
  marking which API routes are auto-mounted under apiPrefix.
50
75
  --json for agents (see POST /api/x is/isn't served, not via 405).
76
+ nifra routes --modes [--target <t>] Show each page route's RENDER MODE (static | isr | ssr),
77
+ hydration, and cache policy in one table. With --target, gate
78
+ against it: a route the target can't honour (an ssr route on a
79
+ static build, ISR where there's no revalidation) exits nonzero
80
+ with the consequence - run in CI so it fails the build, not prod.
51
81
  nifra init-agents [--force] [--json] Retrofit an EXISTING app with the agent-discovery files a new
52
82
  app ships: .mcp.json + .cursor/mcp.json (register this project's
53
83
  nifra MCP), CLAUDE.md (MCP-first preamble + @AGENTS.md import),
@@ -60,7 +90,7 @@ Usage:
60
90
  nifra_example (verified snippets), nifra_scaffold (route→file),
61
91
  nifra_check (drift gate + fixes), nifra_levels (verification
62
92
  ladder), nifra_doctor (deps + duplicate installs).
63
- nifra docs-mcp [--port <n>] Serve the PUBLIC docs MCP over HTTP (nifra_docs + nifra_example) —
93
+ nifra docs-mcp [--port <n>] Serve the PUBLIC docs MCP over HTTP (nifra_docs + nifra_example) -
64
94
  self-host on a VPS so any remote agent can learn nifra. Default :8787.
65
95
  nifra check [--json] [--lints-only] Gate: typecheck + lints (hand-rolled fetch(), untyped client("…"),
66
96
  server-only imports in routes/). Run as "done"; --json for agents;
@@ -68,13 +98,16 @@ Usage:
68
98
  nifra sync-manifest Regenerate a committed web server-manifest.ts from routes/ WITHOUT
69
99
  a full build - clears server-manifest drift after a route add/rename
70
100
  (a new hydrating component still needs a full build).
101
+ nifra sync-routes Regenerate nifra-routes.d.ts from routes/ so navigate({ to, search })
102
+ is typed against each static route's searchSchema (a stale shape is a
103
+ tsc error). Include the file in your tsconfig. Pure file write.
71
104
  nifra snapshot [--out <file>] Write the backend's API contract (routes + schemas) as plain
72
- JSON — the baseline for \`nifra diff\`. Default api-snapshot.json.
105
+ JSON - the baseline for \`nifra diff\`. Default api-snapshot.json.
73
106
  nifra diff [<baseline>] [--json] Breaking-change gate: re-snapshot the contract and compare
74
107
  against the committed baseline. Direction-aware (a new required
75
108
  request field or a removed response field breaks; widening a
76
109
  request enum or adding a response field doesn't) and fails
77
- closed. Exits non-zero on any breaking change — run it in CI.
110
+ closed. Exits non-zero on any breaking change - run it in CI.
78
111
  nifra assure [--config <file>] [--json] Route-assurance gate: load nifra.assurance.ts, classify every
79
112
  reflected backend route, and fail when required enforcement
80
113
  evidence is missing/forbidden or a route is unclassified.
@@ -107,7 +140,7 @@ Usage:
107
140
  [--ci] [--strict] matrix (in-memory stores, in-process cron/WebSocket, Bun/Deno
108
141
  globals, node: builtins) with file:line evidence. --target auto-
109
142
  detected from build/deploy scripts, wrangler.toml, or vercel config;
110
- --ci (or any --target) exits non-zero when a used feature is
143
+ --ci (or any --target) exits nonzero when a used feature is
111
144
  unsupported on the target; --strict also fails on caveats.
112
145
 
113
146
  Reads nifra.config.ts (adapter + clientModule + plugins; or framework.ts), backend.ts (optional), and
@@ -117,7 +150,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
117
150
  \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
118
151
 
119
152
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
120
- const CLI_VERSION = "2.1.0"
153
+ const CLI_VERSION = "2.3.0"
121
154
 
122
155
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
123
156
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
@@ -125,7 +158,153 @@ const asBunPlugins = (v: readonly unknown[]): BunPlugin[] => v as BunPlugin[]
125
158
  const apiOf = (backend: unknown): { api?: unknown } =>
126
159
  backend === undefined ? {} : { api: inProcessClient(backend as never) }
127
160
 
161
+ /**
162
+ * Render an error for the CLI, unwrapping the detail a bare `.message` drops.
163
+ *
164
+ * `Bun.build` reports a bundling failure as an `AggregateError` whose `.message` is a generic
165
+ * "Bundle failed" and whose `.errors` hold the real causes - the unresolved import, the plugin that
166
+ * threw, each with a file and line. `buildClient`/`buildServer` catch a RETURNED `{ success: false }`
167
+ * and print `result.logs`, but a `Bun.build` that THROWS (a plugin `onLoad`/`onResolve` that throws, a
168
+ * resolution failure) rejects before that check runs, so the AggregateError reaches the CLI whole and a
169
+ * catch printing only `.message` throws the causes away. That is the difference between "Bundle failed"
170
+ * and "Could not resolve ./db from routes/x.tsx". So unwrap an AggregateError - directly, or one carried
171
+ * as a `.cause` - into a line per underlying error.
172
+ */
173
+ export function formatCliError(err: unknown): string {
174
+ const aggregate =
175
+ err instanceof AggregateError
176
+ ? err
177
+ : err instanceof Error && err.cause instanceof AggregateError
178
+ ? err.cause
179
+ : undefined
180
+ if (aggregate !== undefined && aggregate.errors.length > 0) {
181
+ const outer = err instanceof Error ? err.message : ""
182
+ const head = outer !== "" && outer !== "Bundle failed" ? outer : "build failed"
183
+ // `String(e)` (not `e.message`) so a Bun BuildMessage/ResolveMessage renders with its file + line,
184
+ // not just the bare text. Multi-line entries are indented to stay under the head. Deduplicated
185
+ // because Bun can repeat one cause across `.errors`.
186
+ const details = [...new Set(aggregate.errors.map((e) => String(e).trim()))].map(
187
+ (line) => ` - ${line.replace(/\n/g, "\n ")}`,
188
+ )
189
+ return [head, ...details].join("\n")
190
+ }
191
+ if (err instanceof Error) return err.message
192
+ return String(err)
193
+ }
194
+
195
+ /**
196
+ * Refuse `nifra dev --bun` for an app Bun's DEV-server bundler cannot compile.
197
+ *
198
+ * Bun's dev server and `Bun.build` are not the same bundler. `Bun.build` transforms `*.module.css` into a
199
+ * scoped class map (the Bun production build of a CSS-Modules app works, verified), but the dev server's
200
+ * bundler does not: the import compiles to a dangling reference and the browser throws
201
+ * `ReferenceError: import_X_module is not defined` from inside the component - a message that names
202
+ * neither CSS Modules nor the dev server, so the cause is invisible.
203
+ *
204
+ * A silently-broken client is the one outcome worth failing the command over, so this checks up front and
205
+ * names the trade plus both ways out. Narrow by design: only the transform proven missing is refused, so
206
+ * an app without CSS Modules still gets the Bun dev loop.
207
+ */
208
+ export async function assertBunDevSupportsApp(app: LoadedApp): Promise<void> {
209
+ const { Glob } = await import("bun")
210
+ const offenders: string[] = []
211
+ for await (const file of new Glob("**/*.module.css").scan({ cwd: app.cwd, dot: false })) {
212
+ if (/(^|\/)(node_modules|dist|\.nifra|\.git)\//.test(file)) continue
213
+ offenders.push(file)
214
+ if (offenders.length >= 5) break
215
+ }
216
+ // Server functions are the same trade with a worse consequence. The client build replaces a `*.fn`
217
+ // module with stubs so its body never reaches a browser; Bun's dev-server bundler takes no plugins, so
218
+ // there it would be bundled WHOLE - the function bodies and every secret they import. Measured, not
219
+ // assumed: a runtime `Bun.plugin` onLoad does not reach `HTMLBundle`, so the module ships raw.
220
+ //
221
+ // `*.server` modules are the same trade again: the client build EMPTIES them, and with no plugins
222
+ // here they ship whole.
223
+ //
224
+ // Both are matched with the SAME regexes the transforms use, imported rather than re-encoded. A
225
+ // hand-written glob here is how these two gates drifted from the pipelines they guard: the old
226
+ // `**/*.fn.{ts,tsx,js,jsx}` missed `.fn.mts`, `.fn.cts`, `.fn.mjs` and `.fn.cjs`, every one of which
227
+ // both build pipelines stub - so a module the framework calls a server function was waved straight
228
+ // into the browser by the check that exists to stop exactly that. A gate written in a second dialect
229
+ // is a gate that eventually disagrees with the thing it guards.
230
+ const leaking: string[] = []
231
+ const serverOnly: string[] = []
232
+ for await (const file of new Glob("**/*.{server,fn,ts,tsx,js,jsx,mts,cts,mjs,cjs}").scan({
233
+ cwd: app.cwd,
234
+ dot: false,
235
+ })) {
236
+ if (/(^|\/)(node_modules|dist|\.nifra|\.git)\//.test(file)) continue
237
+ // Strip the source extension once so the conventions' regexes see `src/todos.fn` / `src/db.server`,
238
+ // which is the shape they match. The glob also includes the extensionless `.fn` / `.server` forms;
239
+ // those already have the matcher-ready shape and pass through unchanged.
240
+ const stem = file.replace(/\.[cm]?[jt]sx?$/, "")
241
+ if (SERVER_FN_MODULE.test(stem)) {
242
+ if (leaking.length < 5) leaking.push(file)
243
+ } else if (SERVER_ONLY_MODULE.test(stem)) {
244
+ if (serverOnly.length < 5) serverOnly.push(file)
245
+ }
246
+ if (leaking.length >= 5 && serverOnly.length >= 5) break
247
+ }
248
+ if (serverOnly.length > 0) {
249
+ throw new Error(
250
+ "[nifra] `nifra dev --bun` can't empty `*.server` modules: Bun's DEV-server bundler takes no " +
251
+ "plugins, so a server-only module would be bundled WHOLE into the browser - along with whatever " +
252
+ "it imports and any secret it holds. `nifra build` and `nifra dev` (Vite) both replace it with " +
253
+ "an empty module, so only this pipeline is affected.\n" +
254
+ ` Server-only modules found: ${serverOnly.join(", ")}\n` +
255
+ " Use `nifra dev` (the Vite pipeline), or reach these from a route/loader for the Bun dev loop.",
256
+ )
257
+ }
258
+ if (leaking.length > 0) {
259
+ throw new Error(
260
+ "[nifra] `nifra dev --bun` can't transform server functions: Bun's DEV-server bundler takes no " +
261
+ "plugins, so a `*.fn` module would be bundled WHOLE into the browser - its function bodies and " +
262
+ "anything they import, secrets included. `nifra build` and `nifra dev` (Vite) both replace it " +
263
+ "with client stubs, so only this pipeline is affected.\n" +
264
+ ` Server-function modules found: ${leaking.join(", ")}\n` +
265
+ " Use `nifra dev` (the Vite pipeline), or move these calls behind a route for the Bun dev loop.",
266
+ )
267
+ }
268
+ if (offenders.length === 0) return
269
+ throw new Error(
270
+ "[nifra] `nifra dev --bun` can't compile CSS Modules: Bun's DEV-server bundler has no `*.module.css` " +
271
+ "transform (its production `Bun.build` does, so `nifra build` is unaffected). The client would load " +
272
+ "with a bare `ReferenceError: import_… is not defined` instead of your component.\n" +
273
+ ` CSS Modules found: ${offenders.join(", ")}\n` +
274
+ " Use `nifra dev` (the Vite pipeline handles them), or drop the CSS Modules to use the Bun dev loop.",
275
+ )
276
+ }
277
+
128
278
  async function dev(app: LoadedApp, flags: Flags): Promise<void> {
279
+ // `--bun`: the Bun-pipeline dev server - Bun.serve's native HMR bundles + hot-reloads the client while
280
+ // Bun's runtime resolves SSR, with no Vite in the process at all. The mirror of `nifra build --vite`:
281
+ // each pipeline is selectable in BOTH phases, and neither ever runs inside the other.
282
+ if (flags.bun) {
283
+ await assertBunDevSupportsApp(app)
284
+ const { createDevServer } = await import("@nifrajs/web/dev")
285
+ const { framework: fw, routesDir, outDir, backend } = app
286
+ const server = await createDevServer({
287
+ routesDir,
288
+ outDir,
289
+ clientModule: fw.clientModule,
290
+ port: flags.port,
291
+ // The Bun pipeline's own plugins (SFC compilers etc.) - never `vitePlugins`, which belong to the
292
+ // other pipeline; `assertPipelineSeparation` already refuses a plugin sitting in the wrong slot.
293
+ plugins: asBunPlugins(app.resolvedPlugins.clientPlugins),
294
+ ...(fw.publicDir !== undefined ? { publicDir: fw.publicDir } : {}),
295
+ ...(fw.conditions ? { conditions: fw.conditions } : {}),
296
+ ...(fw.define ? { define: fw.define } : {}),
297
+ createApp: (clientEntry, importQuery) =>
298
+ createWebApp({
299
+ adapter: asAdapter(fw.adapter),
300
+ manifest: discoverRoutes(routesDir, { importQuery }),
301
+ clientEntry,
302
+ ...apiOf(backend),
303
+ }),
304
+ })
305
+ console.log(`nifra dev (bun) → http://localhost:${server.port}`)
306
+ return
307
+ }
129
308
  // Preflight: `nifra dev` needs `vite` resolvable from the project. Run via `bunx @nifrajs/cli dev` the CLI
130
309
  // sits in an isolated install where the project's peer deps don't resolve, so the vite import below fails
131
310
  // with an opaque ERR_MODULE_NOT_FOUND. Surface the real fix instead.
@@ -137,26 +316,34 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
137
316
  "script (`bun run dev`), not `bunx @nifrajs/cli dev` (which can't resolve the project's peers).",
138
317
  )
139
318
  }
140
- const { plugin } = await import("bun")
141
319
  const { createViteDevServer } = await import("@nifrajs/web/vite")
142
320
  const { framework: fw, routesDir, cwd, backend } = app
143
- // nifra SSRs each request by importing the route modules through Bun (not Vite's ssrLoadModule), so
144
- // `.vue`/`.svelte`/Solid routes need their SSR compile plugin registered on Bun's runtime first —
145
- // the dev analog of `nifra start`. React/Preact JSX is Bun-native → no server plugins → no-op.
146
- for (const p of asBunPlugins(await resolvePlugins(fw.serverPlugins))) plugin(p)
321
+ // No Bun SSR plugins here. `nifra dev` runs the VITE pipeline, and Vite now resolves and compiles
322
+ // route modules for SSR too (via `ssrLoadModule`), so `.vue`/`.svelte`/Solid are handled by the
323
+ // app's `vitePlugins` on both halves.
324
+ //
325
+ // Registering Bun's SFC plugins alongside was the structural intermix: two toolchains compiling the
326
+ // same file in one process, with only one of them governed by Vite's `resolve.dedupe`. That is what
327
+ // made an app's hand-written React alias fail to reach SSR, and it is the condition being removed -
328
+ // not a redundancy worth keeping "just in case".
147
329
  const server = await createViteDevServer({
148
330
  root: cwd,
149
331
  routesDir,
150
332
  clientModule: fw.clientModule,
151
- plugins: await resolvePlugins(fw.vitePlugins),
333
+ plugins: app.resolvedPlugins.vitePlugins,
334
+ ...(fw.publicDir !== undefined ? { publicDir: fw.publicDir } : {}),
335
+ ...(fw.publicEnvPrefix !== undefined ? { publicEnvPrefix: fw.publicEnvPrefix } : {}),
152
336
  poll: flags.poll,
153
337
  port: flags.port,
154
338
  ...(fw.conditions ? { conditions: fw.conditions } : {}),
155
339
  ...(fw.define ? { define: fw.define } : {}),
156
- createApp: (clientEntry, importQuery) =>
340
+ // `load` resolves route modules through VITE, not through Bun. That is what makes the Vite
341
+ // pipeline own the whole phase: the client and the server now agree on every specifier, so
342
+ // `resolve.dedupe` finally governs SSR and the dual-React crash cannot occur.
343
+ createApp: (clientEntry, load) =>
157
344
  createWebApp({
158
345
  adapter: asAdapter(fw.adapter),
159
- manifest: discoverRoutes(routesDir, { importQuery }),
346
+ manifest: discoverRoutes(routesDir, { load }),
160
347
  clientEntry,
161
348
  ...apiOf(backend),
162
349
  }),
@@ -165,19 +352,33 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
165
352
  }
166
353
 
167
354
  /**
168
- * `nifra build --target <t>` — package the engine (buildClient + buildServer + prerender) into one
355
+ * `nifra build --target <t>` - package the engine (buildClient + buildServer + prerender) into one
169
356
  * command that emits a full deploy dir, so an app no longer hand-writes build-bun.ts + _worker.ts +
170
- * _routes.json per target. The adapter is imported from `framework.ts` (the edge-bundlable file — never
357
+ * _routes.json per target. The adapter is imported from `framework.ts` (the edge-bundlable file - never
171
358
  * `nifra.config.ts`, which pulls in Vite plugins), and the backend from `backend.ts` when present;
172
359
  * `buildTarget` generates the per-target server entry from those + the app's `routes/`.
173
360
  */
174
- async function buildForTarget(app: LoadedApp, target: string, report: boolean): Promise<void> {
175
- const { buildTarget, isBuildTarget, renderSizeReport, BUILD_TARGETS } = await import(
176
- "@nifrajs/web/build"
177
- )
361
+ async function buildForTarget(app: LoadedApp, target: string, flags: Flags): Promise<void> {
362
+ const { isBuildTarget, renderSizeReport, BUILD_TARGETS } = await import("@nifrajs/web/build")
178
363
  if (!isBuildTarget(target)) {
179
364
  throw new Error(`[nifra] unknown --target "${target}". Valid: ${BUILD_TARGETS.join(", ")}.`)
180
365
  }
366
+ if (flags.vite && flags.bun) {
367
+ throw new Error("[nifra] `nifra build` takes `--vite` or `--bun`, not both.")
368
+ }
369
+ // Which bundler builds this app. Default is Bun; an app whose ONLY transforms are `vitePlugins` gets
370
+ // Vite instead, because the Bun build cannot run them and would silently omit the work. `--vite`/`--bun`
371
+ // force it, and the one forced combination that would drop transforms throws.
372
+ const decision = chooseBuildPipeline(
373
+ app.resolvedPlugins,
374
+ flags.vite ? "vite" : flags.bun ? "bun" : undefined,
375
+ )
376
+ const useVite = decision.pipeline === "vite"
377
+ // Same deploy-dir output - buildTargetVite delegates to the same orchestrator as buildTarget - so only
378
+ // the bundler and the plugin FORMAT (Vite plugins, not Bun) differ.
379
+ const buildTarget = useVite
380
+ ? (await import("@nifrajs/web/build-vite")).buildTargetVite
381
+ : (await import("@nifrajs/web/build")).buildTarget
181
382
  const { framework: fw, routesDir, outDir, cwd, backend } = app
182
383
  // The server entry must import the adapter from `framework.ts` (edge-safe), not the loaded config.
183
384
  // `loadApp` guarantees one of them exists; prefer framework.ts so a multi-target app's Vite-plugin
@@ -188,6 +389,18 @@ async function buildForTarget(app: LoadedApp, target: string, report: boolean):
188
389
  ? resolve(cwd, "nifra.config.ts")
189
390
  : resolve(cwd, "framework.ts")
190
391
  const backendFile = resolve(cwd, "backend.ts")
392
+ // Plugin FORMAT differs by pipeline: the Bun build takes Bun plugins (clientPlugins/serverPlugins); the
393
+ // Vite build takes the app's Vite plugins (fw.vitePlugins) for BOTH halves. buildTargetWith forwards
394
+ // whatever it's given straight to the chosen bundler, which casts to its own plugin type.
395
+ const plugins = useVite
396
+ ? {
397
+ clientPlugins: app.resolvedPlugins.vitePlugins as unknown as BunPlugin[],
398
+ serverPlugins: app.resolvedPlugins.vitePlugins as unknown as BunPlugin[],
399
+ }
400
+ : {
401
+ clientPlugins: asBunPlugins(app.resolvedPlugins.clientPlugins),
402
+ serverPlugins: asBunPlugins(app.resolvedPlugins.serverPlugins),
403
+ }
191
404
  const result = await buildTarget(target, {
192
405
  routesDir,
193
406
  outDir,
@@ -195,21 +408,25 @@ async function buildForTarget(app: LoadedApp, target: string, report: boolean):
195
408
  clientModule: fw.clientModule,
196
409
  adapterImport: frameworkFile,
197
410
  ...(backend !== undefined && existsSync(backendFile) ? { backendImport: backendFile } : {}),
198
- clientPlugins: asBunPlugins(await resolvePlugins(fw.clientPlugins)),
199
- serverPlugins: asBunPlugins(await resolvePlugins(fw.serverPlugins)),
411
+ ...plugins,
200
412
  ...(fw.conditions ? { conditions: fw.conditions } : {}),
201
413
  ...(fw.define ? { define: fw.define } : {}),
202
- // The static target needs a built app to drive prerendering — only build it when targeting static.
414
+ ...(fw.publicDir !== undefined ? { publicDir: fw.publicDir } : {}),
415
+ ...(fw.publicEnvPrefix !== undefined ? { publicEnvPrefix: fw.publicEnvPrefix } : {}),
416
+ // The static target needs a built app to drive prerendering - only build it when targeting static.
203
417
  ...(target === "static" ? { prerenderApp: await buildPrerenderApp(app) } : {}),
204
418
  })
205
- console.log(`nifra build (${target}) → ${result.run}`)
206
- if (report) console.log(`\n${renderSizeReport(result.size)}`)
419
+ console.log(`nifra build (${target}${useVite ? ", vite" : ""}) → ${result.run}`)
420
+ // An auto-selected pipeline is stated, never silent: the user asked for the default and got something
421
+ // else, and the reason is the thing that makes that legible instead of surprising.
422
+ if (decision.reason !== undefined) console.log(` ${decision.reason}`)
423
+ if (flags.report) console.log(`\n${renderSizeReport(result.size)}`)
207
424
  }
208
425
 
209
426
  /** Build the app FACTORY for the `static` target's prerender pass. Registers the
210
427
  * framework's SSR Bun plugins (so `.vue`/`.svelte`/Solid routes import) once, then return a factory that
211
428
  * `createWebApp`s the app for the client build's manifest. The client entry MUST be the real content-hashed
212
- * bundle (`client.entry`) — it's the hydration `<script src>` the prerendered HTML emits, so a placeholder
429
+ * bundle (`client.entry`) - it's the hydration `<script src>` the prerendered HTML emits, so a placeholder
213
430
  * would 404 and the pages would render but never hydrate (inert controls). Styles/route-preload are wired
214
431
  * from the same manifest so the static HTML matches the deployed app. */
215
432
  async function buildPrerenderApp(
@@ -217,7 +434,7 @@ async function buildPrerenderApp(
217
434
  ): Promise<(client: BuiltManifest) => { fetch(req: Request): Response | Promise<Response> }> {
218
435
  const { plugin } = await import("bun")
219
436
  const { framework: fw, routesDir, backend } = app
220
- for (const p of asBunPlugins(await resolvePlugins(fw.serverPlugins))) plugin(p)
437
+ for (const p of asBunPlugins(app.resolvedPlugins.serverPlugins)) plugin(p)
221
438
  return (client) =>
222
439
  createWebApp({
223
440
  adapter: asAdapter(fw.adapter),
@@ -250,7 +467,7 @@ async function start(app: LoadedApp, flags: Flags): Promise<void> {
250
467
  )
251
468
  }
252
469
  throw new Error(
253
- `[nifra] no ${serverFile} — run \`nifra build\` or \`nifra build --target bun\` first.`,
470
+ `[nifra] no ${serverFile} - run \`nifra build\` or \`nifra build --target bun\` first.`,
254
471
  )
255
472
  }
256
473
  Bun.env.PORT = String(flags.port)
@@ -266,6 +483,8 @@ export function parseFlags(args: readonly string[]): Flags {
266
483
  let poll = Bun.env.CHOKIDAR_USEPOLLING === "1"
267
484
  let target = "bun"
268
485
  let report = false
486
+ let vite = false
487
+ let bun = false
269
488
  for (let i = 0; i < args.length; i++) {
270
489
  const a = args[i]
271
490
  if ((a === "--port" || a === "-p") && args[i + 1]) port = Number(args[++i])
@@ -273,11 +492,13 @@ export function parseFlags(args: readonly string[]): Flags {
273
492
  else if (a === "--poll") poll = true
274
493
  else if (a === "--target" && args[i + 1]) target = args[++i] as string
275
494
  else if (a === "--report") report = true
495
+ else if (a === "--vite") vite = true
496
+ else if (a === "--bun") bun = true
276
497
  }
277
498
  if (!Number.isFinite(port) || port < 0 || port > 65535) {
278
499
  throw new Error(`[nifra] invalid --port: ${port}`)
279
500
  }
280
- return { port, out, poll, target, report }
501
+ return { port, out, poll, target, report, vite, bun }
281
502
  }
282
503
 
283
504
  async function main(): Promise<void> {
@@ -291,14 +512,14 @@ async function main(): Promise<void> {
291
512
  console.log(`nifra (@nifrajs/cli) ${CLI_VERSION}`)
292
513
  return
293
514
  }
294
- // `mcp` runs a long-lived stdio server and loads the project lazily per-tool — it must not go through
515
+ // `mcp` runs a long-lived stdio server and loads the project lazily per-tool - it must not go through
295
516
  // the eager `loadApp` below (which would fail fast on a project that's API-only / not yet built).
296
517
  if (command === "mcp") {
297
518
  const { runMcpServer } = await import("./mcp.ts")
298
519
  await runMcpServer(process.cwd(), CLI_VERSION)
299
520
  return
300
521
  }
301
- // `docs-mcp` runs the PUBLIC docs MCP over HTTP — project-independent (serves the bundled corpus), so
522
+ // `docs-mcp` runs the PUBLIC docs MCP over HTTP - project-independent (serves the bundled corpus), so
302
523
  // it self-hosts anywhere Bun runs (a VPS behind a reverse proxy, a container). Long-lived; no loadApp.
303
524
  if (command === "docs-mcp") {
304
525
  const { handleMcpHttp } = await import("./mcp-http.ts")
@@ -311,7 +532,7 @@ async function main(): Promise<void> {
311
532
  console.log(`nifra docs MCP (HTTP) → ${server.url}`)
312
533
  return
313
534
  }
314
- // `check` is a pure cwd-based gate (typecheck + lint) — it must run even when the project doesn't
535
+ // `check` is a pure cwd-based gate (typecheck + lint) - it must run even when the project doesn't
315
536
  // load (API-only, not built yet), so it dispatches before the eager `loadApp` below.
316
537
  if (command === "check") {
317
538
  const { runCheck } = await import("./check.ts")
@@ -343,7 +564,14 @@ async function main(): Promise<void> {
343
564
  if (!(await runSyncManifest(process.cwd()))) process.exitCode = 1
344
565
  return
345
566
  }
346
- // `doctor` is a pure cwd check (imports vs declared deps) — like `check`, dispatch before `loadApp`
567
+ // `sync-routes` regenerates the route-search types `.d.ts` (typed `navigate({ to, search })`) from
568
+ // `routes/`. Pure fs/text (needs only the route tree, not `framework.ts`), so dispatch before `loadApp`.
569
+ if (command === "sync-routes") {
570
+ const { runSyncRoutes } = await import("./sync-routes.ts")
571
+ if (!(await runSyncRoutes(process.cwd()))) process.exitCode = 1
572
+ return
573
+ }
574
+ // `doctor` is a pure cwd check (imports vs declared deps) - like `check`, dispatch before `loadApp`
347
575
  // so it runs on an API-only / not-yet-built project.
348
576
  if (command === "doctor") {
349
577
  const { runDoctor } = await import("./doctor.ts")
@@ -356,7 +584,7 @@ async function main(): Promise<void> {
356
584
  process.exitCode = 1
357
585
  return
358
586
  }
359
- // `snapshot` / `diff` load ONLY backend.ts (the API contract) — like `check`, they must run on an
587
+ // `snapshot` / `diff` load ONLY backend.ts (the API contract) - like `check`, they must run on an
360
588
  // API-only project, so they dispatch before the eager `loadApp`.
361
589
  if (command === "snapshot") {
362
590
  const { runSnapshot } = await import("./diff-tool.ts")
@@ -370,7 +598,7 @@ async function main(): Promise<void> {
370
598
  try {
371
599
  await runSnapshot(process.cwd(), out !== undefined ? { out } : {})
372
600
  } catch (err) {
373
- console.error(err instanceof Error ? err.message : String(err))
601
+ console.error(formatCliError(err))
374
602
  process.exitCode = 1
375
603
  }
376
604
  return
@@ -383,7 +611,7 @@ async function main(): Promise<void> {
383
611
  process.exitCode = 1
384
612
  }
385
613
  } catch (err) {
386
- console.error(err instanceof Error ? err.message : String(err))
614
+ console.error(formatCliError(err))
387
615
  process.exitCode = 1
388
616
  }
389
617
  return
@@ -408,7 +636,7 @@ async function main(): Promise<void> {
408
636
  )
409
637
  process.exitCode = 1
410
638
  } catch (err) {
411
- console.error(err instanceof Error ? err.message : String(err))
639
+ console.error(formatCliError(err))
412
640
  process.exitCode = 1
413
641
  }
414
642
  return
@@ -449,7 +677,7 @@ async function main(): Promise<void> {
449
677
  throw new Error("[nifra] capabilities needs `snapshot` or `check`")
450
678
  }
451
679
  } catch (err) {
452
- console.error(err instanceof Error ? err.message : String(err))
680
+ console.error(formatCliError(err))
453
681
  process.exitCode = 1
454
682
  }
455
683
  return
@@ -491,7 +719,7 @@ async function main(): Promise<void> {
491
719
  throw new Error("[nifra] manifest needs `emit` or `diff`")
492
720
  }
493
721
  } catch (err) {
494
- console.error(err instanceof Error ? err.message : String(err))
722
+ console.error(formatCliError(err))
495
723
  process.exitCode = 1
496
724
  }
497
725
  return
@@ -522,13 +750,13 @@ async function main(): Promise<void> {
522
750
  )
523
751
  process.exitCode = 1
524
752
  } catch (err) {
525
- console.error(err instanceof Error ? err.message : String(err))
753
+ console.error(formatCliError(err))
526
754
  process.exitCode = 1
527
755
  }
528
756
  return
529
757
  }
530
758
  // `upgrade` is a pure cwd file-transformer (package.json pins + import moves) driven by a per-release
531
- // recipe, then verified with `nifra check`. Dispatch before the eager `loadApp` — it must run on any
759
+ // recipe, then verified with `nifra check`. Dispatch before the eager `loadApp` - it must run on any
532
760
  // repo (API-only, not built, or mid-upgrade with edits that don't yet typecheck under dry-run).
533
761
  if (command === "upgrade") {
534
762
  const { runUpgrade } = await import("./upgrade.ts")
@@ -543,12 +771,12 @@ async function main(): Promise<void> {
543
771
  })
544
772
  if (!ok) process.exitCode = 1
545
773
  } catch (err) {
546
- console.error(err instanceof Error ? err.message : String(err))
774
+ console.error(formatCliError(err))
547
775
  process.exitCode = 1
548
776
  }
549
777
  return
550
778
  }
551
- // `port` is a pure cwd-based portability linter (scans source, doesn't run the app) — like `check`/
779
+ // `port` is a pure cwd-based portability linter (scans source, doesn't run the app) - like `check`/
552
780
  // `doctor`, dispatch before the eager `loadApp` so it runs on an API-only / not-yet-built project.
553
781
  if (command === "port") {
554
782
  const { runPort } = await import("./port.ts")
@@ -568,7 +796,7 @@ async function main(): Promise<void> {
568
796
  })
569
797
  if (!passed) process.exitCode = 1
570
798
  } catch (err) {
571
- console.error(err instanceof Error ? err.message : String(err))
799
+ console.error(formatCliError(err))
572
800
  process.exitCode = 1
573
801
  }
574
802
  return
@@ -588,10 +816,34 @@ async function main(): Promise<void> {
588
816
  const flags = parseFlags(argv.slice(1))
589
817
  const app = await loadApp(process.cwd(), flags.out)
590
818
  if (command === "dev") await dev(app, flags)
591
- else if (command === "build") await buildForTarget(app, flags.target, flags.report)
819
+ else if (command === "build") await buildForTarget(app, flags.target, flags)
592
820
  else if (command === "context") console.log(describeProject(app))
593
821
  else if (command === "routes") {
594
- console.log(await describeRoutes(app, { json: argv.includes("--json") }))
822
+ if (argv.includes("--modes")) {
823
+ // `--modes` answers a different question than the default table: not "what URLs exist" but "how
824
+ // does each render, and can the target I'm about to deploy to actually honour that". Resolves
825
+ // against `--target` when given, so a `static` build's server-needing route fails here, in one
826
+ // place, instead of 404ing in production.
827
+ const { buildRouteManifest, renderRouteManifest } = await import(
828
+ "@nifrajs/web/route-manifest"
829
+ )
830
+ const manifest = discoverRoutes(app.routesDir)
831
+ // `flags.target` defaults to "bun" for `build`; here an EXPLICIT `--target` is what opts into
832
+ // gating, so read it from argv rather than inheriting the build default (which would silently gate
833
+ // every `nifra routes --modes` against bun).
834
+ const targetIdx = argv.indexOf("--target")
835
+ const target = targetIdx !== -1 ? argv[targetIdx + 1] : undefined
836
+ const routeManifest = await buildRouteManifest(manifest, {
837
+ ...(target !== undefined ? { target } : {}),
838
+ })
839
+ if (argv.includes("--json")) console.log(JSON.stringify(routeManifest, null, 2))
840
+ else console.log(renderRouteManifest(routeManifest))
841
+ // A target that cannot honour a route is a build-breaking fact, not a note: exit non-zero so the
842
+ // same command in CI gates the deploy it is warning about.
843
+ if (routeManifest.conflicts.length > 0) process.exitCode = 1
844
+ } else {
845
+ console.log(await describeRoutes(app, { json: argv.includes("--json") }))
846
+ }
595
847
  } else await start(app, flags)
596
848
  }
597
849
 
@@ -599,7 +851,7 @@ async function main(): Promise<void> {
599
851
  // exported `parseFlags`. `import.meta.main` is true only for the process's entry module.
600
852
  if (import.meta.main) {
601
853
  main().catch((err) => {
602
- console.error(err instanceof Error ? err.message : String(err))
854
+ console.error(formatCliError(err))
603
855
  process.exitCode = 1
604
856
  })
605
857
  }