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