@nifrajs/cli 2.9.1 → 2.11.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 (55) hide show
  1. package/README.md +2 -1
  2. package/dist/check.d.ts +9 -1
  3. package/dist/check.d.ts.map +1 -1
  4. package/dist/check.js +38 -6
  5. package/dist/check.js.map +1 -1
  6. package/dist/cli.d.ts +2 -18
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +129 -66
  9. package/dist/cli.js.map +1 -1
  10. package/dist/dev-bun-config.d.ts +17 -6
  11. package/dist/dev-bun-config.d.ts.map +1 -1
  12. package/dist/dev-bun-config.js +51 -14
  13. package/dist/dev-bun-config.js.map +1 -1
  14. package/dist/doctor.d.ts +10 -0
  15. package/dist/doctor.d.ts.map +1 -1
  16. package/dist/doctor.js +17 -1
  17. package/dist/doctor.js.map +1 -1
  18. package/dist/introspect.d.ts +26 -6
  19. package/dist/introspect.d.ts.map +1 -1
  20. package/dist/introspect.js +92 -2
  21. package/dist/introspect.js.map +1 -1
  22. package/dist/load.d.ts +4 -0
  23. package/dist/load.d.ts.map +1 -1
  24. package/dist/load.js +1 -0
  25. package/dist/load.js.map +1 -1
  26. package/dist/mcp-http.js +1 -1
  27. package/dist/mcp-http.js.map +1 -1
  28. package/dist/mcp.js +2 -2
  29. package/dist/mcp.js.map +1 -1
  30. package/dist/pipeline-guard.d.ts +25 -10
  31. package/dist/pipeline-guard.d.ts.map +1 -1
  32. package/dist/pipeline-guard.js +35 -15
  33. package/dist/pipeline-guard.js.map +1 -1
  34. package/dist/pipeline-report.d.ts +34 -0
  35. package/dist/pipeline-report.d.ts.map +1 -0
  36. package/dist/pipeline-report.js +282 -0
  37. package/dist/pipeline-report.js.map +1 -0
  38. package/dist/sdk.d.ts +10 -0
  39. package/dist/sdk.d.ts.map +1 -0
  40. package/dist/sdk.js +229 -0
  41. package/dist/sdk.js.map +1 -0
  42. package/docs/llms-full.txt +246 -17
  43. package/docs/types.json +725 -16
  44. package/package.json +9 -9
  45. package/src/check.ts +54 -5
  46. package/src/cli.ts +131 -67
  47. package/src/dev-bun-config.ts +57 -14
  48. package/src/doctor.ts +28 -1
  49. package/src/introspect.ts +124 -4
  50. package/src/load.ts +5 -0
  51. package/src/mcp-http.ts +1 -1
  52. package/src/mcp.ts +2 -2
  53. package/src/pipeline-guard.ts +44 -14
  54. package/src/pipeline-report.ts +341 -0
  55. package/src/sdk.ts +260 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/cli",
3
- "version": "2.9.1",
3
+ "version": "2.11.0",
4
4
  "description": "The nifra CLI - zero-config dev/build/start for a nifra app (`nifra dev`, `nifra build`, `nifra start`).",
5
5
  "keywords": [
6
6
  "bun",
@@ -41,14 +41,14 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@nifrajs/client": "2.9.1",
45
- "@nifrajs/core": "2.9.1",
46
- "@nifrajs/mcp": "2.9.1",
47
- "@nifrajs/runner": "2.9.1",
48
- "@nifrajs/schema": "2.9.1",
49
- "@nifrajs/testing": "2.9.1",
50
- "@nifrajs/web": "2.9.1",
51
- "create-nifra": "2.9.1"
44
+ "@nifrajs/client": "2.11.0",
45
+ "@nifrajs/core": "2.11.0",
46
+ "@nifrajs/mcp": "2.11.0",
47
+ "@nifrajs/runner": "2.11.0",
48
+ "@nifrajs/schema": "2.11.0",
49
+ "@nifrajs/testing": "2.11.0",
50
+ "@nifrajs/web": "2.11.0",
51
+ "create-nifra": "2.11.0"
52
52
  },
53
53
  "repository": {
54
54
  "type": "git",
package/src/check.ts CHANGED
@@ -23,6 +23,9 @@ import { Glob } from "bun"
23
23
  import type * as TSApi from "typescript"
24
24
  import type { CapabilityProjectReport } from "./capabilities-tool.ts"
25
25
  import { importTypeScript, type TypeScriptApi } from "./internal/typescript-import.ts"
26
+ // Type-only: `pipeline-report.ts` imports this module's source scanners, so a value import here would
27
+ // close a cycle. Doctor is what actually runs the collector (see the `pipeline` rule below).
28
+ import type { PipelineReport } from "./pipeline-report.ts"
26
29
 
27
30
  export interface SourceFinding {
28
31
  readonly file: string
@@ -1396,6 +1399,7 @@ export interface CheckDiagnostic {
1396
1399
  | "undeclared-dependency"
1397
1400
  | "duplicate-install"
1398
1401
  | "stale-workspace-dist"
1402
+ | "pipeline"
1399
1403
  | "server-manifest-drift"
1400
1404
  | "manifest-drift"
1401
1405
  | "capability-assurance"
@@ -1443,6 +1447,13 @@ export interface CheckResult {
1443
1447
  readonly ok: boolean
1444
1448
  readonly typecheck: "pass" | "fail" | "skipped"
1445
1449
  readonly diagnostics: readonly CheckDiagnostic[]
1450
+ /**
1451
+ * Which bundler this app's phases run on, read statically from the config, and how nifra concluded
1452
+ * it. Returned even when nothing is wrong: an agent reading this project has to know which plugin
1453
+ * slot is live and which toolchain compiles a component before its next edit, and every `pipeline`
1454
+ * diagnostic below is only interpretable against it. Absent when the directory is not a nifra app.
1455
+ */
1456
+ readonly pipeline?: PipelineReport
1446
1457
  /** Intentional non-typed mount prefixes declared in `nifra.check.json` (e.g. `/auth` for a mounted
1447
1458
  * better-auth). Echoed here so `--json` / the MCP tool / the report can show what the typed-client scan
1448
1459
  * deliberately skipped - a suppressed prefix stays auditable instead of silently hiding real drift. */
@@ -1507,6 +1518,8 @@ const SERVER_IMPORT_HINT =
1507
1518
  "server-only import in a route module (bundled for the browser) - reach it via c.db / ctx.api inside a loader, never a top-level import"
1508
1519
  const RESPONSE_ROUTE_HINT =
1509
1520
  "route handler returns a raw Response - the typed client infers `data: never`, so drift detection is lost for this route. Return a plain object (it's serialized for you); for a stream use a typed SSE route (`app.sse(...)`), which keeps typed events; or, if a raw Response is intended (file/redirect), add `{ response: t.… }` or a `// nifra-expect raw-response` comment to mark it and silence this"
1521
+ const PIPELINE_DOC_HINT =
1522
+ "nifra runs one bundler per phase - `vitePlugins` feed Vite, `clientPlugins`/`serverPlugins` feed Bun, and the file `nifra build` imports the adapter from is bundled into the server. See the Gotchas section of the Dev & HMR guide."
1510
1523
  const UNDECLARED_DEP_HINT =
1511
1524
  "imported package is not declared in package.json dependencies - run bun add to declare it"
1512
1525
  const SQL_COMPILER_MISSING_HINT =
@@ -1944,6 +1957,21 @@ export async function collectCheckResult(
1944
1957
  })
1945
1958
  }
1946
1959
  }
1960
+ // The two-pipeline rule, read from the config as TEXT (doctor collects it - see ./pipeline-report.ts).
1961
+ // `loadApp` already refuses a misplaced plugin, but only when something loads the app; a check that
1962
+ // never executes project code, and CI that never starts a dev server, would otherwise meet these for
1963
+ // the first time as a production server that built cleanly and died at startup.
1964
+ for (const f of dr.pipeline?.findings ?? []) {
1965
+ diagnostics.push({
1966
+ rule: "pipeline",
1967
+ severity: f.severity,
1968
+ file: f.file,
1969
+ ...(f.line !== undefined ? { line: f.line } : {}),
1970
+ message: f.message,
1971
+ fix: f.fix,
1972
+ suggestion: { kind: "manual", title: f.fix, steps: [PIPELINE_DOC_HINT] },
1973
+ })
1974
+ }
1947
1975
  // #7: a committed server-manifest.ts that drifted from routes/ - name the exact missing/extra routes.
1948
1976
  for (const f of manifestDrift) {
1949
1977
  const parts: string[] = []
@@ -2117,6 +2145,7 @@ export async function collectCheckResult(
2117
2145
  ok: !diagnostics.some((diagnostic) => diagnostic.severity === "error"),
2118
2146
  typecheck: tc.ran ? (tc.ok ? "pass" : "fail") : "skipped",
2119
2147
  diagnostics: shown,
2148
+ ...(dr.pipeline !== undefined ? { pipeline: dr.pipeline } : {}),
2120
2149
  ...(checkConfig.externalMounts.length > 0
2121
2150
  ? { externalMounts: checkConfig.externalMounts }
2122
2151
  : {}),
@@ -2146,6 +2175,16 @@ export async function runCheck(
2146
2175
  ? "✗ typecheck failed - the frontend/backend contract is broken"
2147
2176
  : "• typecheck skipped (no tsconfig / typescript not installed)",
2148
2177
  )
2178
+ // Stated on every run, passing or not. "Which bundler is this app on" decides which plugin slot is
2179
+ // live and which toolchain compiles a component, so it belongs in the report rather than only in the
2180
+ // dev server's banner - where CI never sees it.
2181
+ if (result.pipeline !== undefined) {
2182
+ console.log(
2183
+ result.pipeline.pipeline === "unknown"
2184
+ ? `• bundler: not readable from ${result.pipeline.configFile} - ${result.pipeline.reason}`
2185
+ : `• bundler: ${result.pipeline.pipeline} (${result.pipeline.reason})`,
2186
+ )
2187
+ }
2149
2188
  if (result.externalMounts !== undefined && result.externalMounts.length > 0) {
2150
2189
  console.log(
2151
2190
  `• intentional external mounts (not typed-client checked): ${result.externalMounts.join(", ")}`,
@@ -2163,6 +2202,7 @@ export async function runCheck(
2163
2202
  ["undeclared-dependency", "undeclared dependency in package.json"],
2164
2203
  ["duplicate-install", "duplicate identity-sensitive dependency install"],
2165
2204
  ["stale-workspace-dist", "workspace-linked dist older than its source"],
2205
+ ["pipeline", "bundler pipeline (Vite/Bun) config"],
2166
2206
  ["server-manifest-drift", "server-manifest.ts drifted from routes/"],
2167
2207
  ["manifest-drift", "versioned trust manifest drift"],
2168
2208
  ["capability-assurance", "effect/capability assurance"],
@@ -2170,11 +2210,20 @@ export async function runCheck(
2170
2210
  ["check-config", "nifra.check.json"],
2171
2211
  ] as const) {
2172
2212
  const ds = counts(rule)
2173
- if (rule === "response-route" || rule === "stale-workspace-dist") {
2174
- // Advisory: surfaced with ⚠, never folded into pass/fail.
2175
- console.log(ds.length === 0 ? `✓ ${label}: none` : `⚠ ${label}: ${ds.length} (advisory)`)
2176
- } else if (rule !== "typecheck") {
2177
- console.log(ds.length === 0 ? `✓ ${label}: none` : `✗ ${label}: ${ds.length}`)
2213
+ if (rule !== "typecheck") {
2214
+ // Marked by SEVERITY, not by rule name. `response-route` and `stale-workspace-dist` are advisory
2215
+ // in whole; `pipeline` is the first rule that is advisory in part (a misplaced plugin fails, a
2216
+ // resolve condition the Bun dev bundler can't take does not), so the counts are split rather than
2217
+ // rounded up to the worse of the two.
2218
+ const errors = ds.filter((d) => d.severity === "error").length
2219
+ const advisory = ds.length - errors
2220
+ console.log(
2221
+ ds.length === 0
2222
+ ? `✓ ${label}: none`
2223
+ : errors === 0
2224
+ ? `⚠ ${label}: ${advisory} (advisory)`
2225
+ : `✗ ${label}: ${errors}${advisory > 0 ? ` (+${advisory} advisory)` : ""}`,
2226
+ )
2178
2227
  }
2179
2228
  for (const d of ds) {
2180
2229
  console.log(` ${d.file ?? ""}${d.line ? `:${d.line}` : ""} ${d.message}`)
package/src/cli.ts CHANGED
@@ -3,7 +3,7 @@
3
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
6
+ * nifra dev true-HMR dev server (Bun native HMR + nifra SSR) - @nifrajs/web/dev
7
7
  * nifra build emit a complete target-specific deploy directory - @nifrajs/web/build
8
8
  * nifra start run the default Bun build - dist/server.js
9
9
  *
@@ -15,9 +15,9 @@ import { inProcessClient } from "@nifrajs/client"
15
15
  import { createWebApp, DEFAULT_DEV_PORT, type RenderAdapter } from "@nifrajs/web"
16
16
  import { discoverRoutes } from "@nifrajs/web/fs"
17
17
  import type { BunPlugin } from "bun"
18
- import { describeProject, describeRoutes } from "./introspect.ts"
18
+ import { describeProject, describeRouteGraph, describeRoutes } from "./introspect.ts"
19
19
  import { type LoadedApp, loadApp } from "./load.ts"
20
- import { chooseBuildPipeline } from "./pipeline-guard.ts"
20
+ import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
21
21
 
22
22
  export interface Flags {
23
23
  readonly port: number
@@ -30,9 +30,8 @@ export interface Flags {
30
30
  /** `nifra build --vite`: force the client + server through Vite/Rollup. Without a flag the pipeline is
31
31
  * chosen per app (`chooseBuildPipeline`) - Bun unless the app's only transforms are `vitePlugins`. */
32
32
  readonly vite: boolean
33
- /** `nifra dev --bun`: run the Bun-pipeline dev server (Bun.serve native HMR - including React Fast
34
- * Refresh with state preserved - and no Vite in the process). Default stays Vite for its plugin
35
- * ecosystem; `--bun` trades that for one bundler across dev and prod. Refuses CSS-Modules apps.
33
+ /** `nifra dev --bun`: force the Bun-pipeline dev server. Rarely needed - `dev` and `build` share one
34
+ * rule, so Bun is already the default unless `vitePlugins` are the app's ONLY transforms.
36
35
  *
37
36
  * `nifra build --bun`: force the Bun build. Refuses when that would drop the app's `vitePlugins`. */
38
37
  readonly bun: boolean
@@ -41,11 +40,14 @@ export interface Flags {
41
40
  const HELP = `nifra - zero-config dev/build/start for a nifra app
42
41
 
43
42
  Usage:
44
- nifra dev [--port <n>] [--poll] Start the true-HMR dev server (Vite). Default port ${DEFAULT_DEV_PORT}.
45
- [--bun] Run the BUN pipeline instead: Bun.serve native HMR (React Fast
46
- Refresh included, state preserved), no Vite in the process - one
47
- bundler across dev and prod. Plain CSS/Tailwind work; refuses an
48
- app using *.module.css (Bun's dev bundler can't transform it).
43
+ nifra dev [--port <n>] [--poll] Start the true-HMR dev server (Bun). Default port ${DEFAULT_DEV_PORT}.
44
+ An app whose ONLY transforms are \`vitePlugins\` uses Vite (the
45
+ Bun pipeline cannot run them); everything else uses Bun.
46
+ [--vite] Force the Vite middleware/HMR pipeline.
47
+ [--bun] Force Bun.serve native HMR (React Fast Refresh included, state
48
+ preserved), no Vite in the process. CSS Modules, plain CSS,
49
+ Tailwind and the app's own \`clientPlugins\`/\`serverPlugins\` run
50
+ through the same Bun transforms production uses.
49
51
  nifra build [--out <dir>] [--report] Emit a complete deploy directory.
50
52
  [--target <t>] Target a FULL deploy dir for <t>:
51
53
  bun | node | deno | cf-pages | vercel | static. Packages
@@ -67,6 +69,8 @@ Usage:
67
69
  routes (routes/) + the in-process backend's API routes,
68
70
  marking which API routes are auto-mounted under apiPrefix.
69
71
  --json for agents (see POST /api/x is/isn't served, not via 405).
72
+ nifra routes --graph [--json] Render the public route surface as a Mermaid graph, or as
73
+ stable JSON for contract explorers.
70
74
  nifra routes --modes [--target <t>] Show each page route's RENDER MODE (static | isr | ssr),
71
75
  hydration, and cache policy in one table. With --target, gate
72
76
  against it: a route the target can't honour (an ssr route on a
@@ -105,6 +109,8 @@ Usage:
105
109
  request field or a removed response field breaks; widening a
106
110
  request enum or adding a response field doesn't) and fails
107
111
  closed. Exits non-zero on any breaking change - run it in CI.
112
+ nifra sdk --lang <python|go> [--out <file>]
113
+ Generate a deterministic non-TypeScript SDK from backend.ts.
108
114
  nifra assure [--config <file>] [--json] Route-assurance gate: load nifra.assurance.ts, classify every
109
115
  reflected backend route, and fail when required enforcement
110
116
  evidence is missing/forbidden or a route is unclassified.
@@ -148,7 +154,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
148
154
  \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
149
155
 
150
156
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
151
- const CLI_VERSION = "2.9.1"
157
+ const CLI_VERSION = "2.11.0"
152
158
 
153
159
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
154
160
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
@@ -190,52 +196,26 @@ export function formatCliError(err: unknown): string {
190
196
  return String(err)
191
197
  }
192
198
 
193
- /**
194
- * Refuse `nifra dev --bun` for an app Bun's DEV-server bundler cannot compile.
195
- *
196
- * Bun's dev server and `Bun.build` are not the same bundler. `Bun.build` transforms `*.module.css` into a
197
- * scoped class map (the Bun production build of a CSS-Modules app works, verified), but the dev server's
198
- * bundler does not: the import compiles to a dangling reference and the browser throws
199
- * `ReferenceError: import_X_module is not defined` from inside the component - a message that names
200
- * neither CSS Modules nor the dev server, so the cause is invisible.
201
- *
202
- * A silently-broken client is the one outcome worth failing the command over, so this checks up front and
203
- * names the trade plus both ways out. Narrow by design: only the transform proven missing is refused, so
204
- * an app without CSS Modules still gets the Bun dev loop.
205
- */
206
- export async function assertBunDevSupportsApp(app: LoadedApp): Promise<void> {
207
- // Server functions and `*.server` modules are handled: the generated bunfig (dev-bun-config.ts)
208
- // delivers the SAME serverFnStubPlugin/serverOnlyEmptyPlugin the production build uses to Bun's
209
- // dev-server bundler via `[serve.static] plugins` - the one channel that bundler accepts plugins
210
- // through (a runtime `Bun.plugin` never reaches it; programmatic ask: oven-sh/bun#36830). The old
211
- // fail-closed refusal for those modules is gone because the leak it guarded is gone.
212
- //
213
- // CSS Modules remain gated: the `*.module.css` plugin rests on an `onResolve` virtual-module
214
- // stylesheet emitter that is not yet verified under `[serve.static]`, and its failure mode is a
215
- // broken client rather than a leak - refuse loudly until it is proven there.
216
- const { Glob } = await import("bun")
217
- const offenders: string[] = []
218
- for await (const file of new Glob("**/*.module.css").scan({ cwd: app.cwd, dot: false })) {
219
- if (/(^|\/)(node_modules|dist|\.nifra|\.git)\//.test(file)) continue
220
- offenders.push(file)
221
- if (offenders.length >= 5) break
199
+ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
200
+ if (flags.vite && flags.bun) {
201
+ throw new Error("[nifra] `nifra dev` takes `--vite` or `--bun`, not both.")
222
202
  }
223
- if (offenders.length === 0) return
224
- throw new Error(
225
- "[nifra] `nifra dev --bun` can't compile CSS Modules: Bun's DEV-server bundler has no `*.module.css` " +
226
- "transform (its production `Bun.build` does, so `nifra build` is unaffected). The client would load " +
227
- "with a bare `ReferenceError: import_… is not defined` instead of your component.\n" +
228
- ` CSS Modules found: ${offenders.join(", ")}\n` +
229
- " Use `nifra dev` (the Vite pipeline handles them), or drop the CSS Modules to use the Bun dev loop.",
203
+ // Default to Bun for the dev phase too. An app whose only transforms are Vite plugins stays on Vite so
204
+ // its transform is never silently dropped; `--vite`/`--bun` make that choice explicit.
205
+ // ONE rule for both phases - `chooseBuildPipeline` decides `dev` exactly as it decides `build`, so a
206
+ // project cannot end up bundled by one toolchain in dev and the other in production. An earlier
207
+ // version of this kept apps declaring `vitePlugins` on Vite for dev even when they also shipped Bun
208
+ // equivalents, on the theory that the framework plugins were the safer HMR path. Measurement killed
209
+ // it: Vue, Solid and Svelte all render correctly on the Bun pipeline, so the hedge bought nothing
210
+ // and cost an app the guarantee that dev and production bundle the same way. (Svelte used to render
211
+ // ONLY on Bun, which made the hedge actively harmful; it now renders on either - see the note at the
212
+ // Vite branch for what the adapter had to be given to make that true.)
213
+ const decision = chooseBuildPipeline(
214
+ app.resolvedPlugins,
215
+ flags.vite ? "vite" : flags.bun ? "bun" : undefined,
216
+ "dev",
230
217
  )
231
- }
232
-
233
- async function dev(app: LoadedApp, flags: Flags): Promise<void> {
234
- // `--bun`: the Bun-pipeline dev server - Bun.serve's native HMR bundles + hot-reloads the client while
235
- // Bun's runtime resolves SSR, with no Vite in the process at all. The mirror of `nifra build --vite`:
236
- // each pipeline is selectable in BOTH phases, and neither ever runs inside the other.
237
- if (flags.bun) {
238
- await assertBunDevSupportsApp(app)
218
+ if (decision.pipeline === "bun") {
239
219
  // Bun's dev-server bundler takes plugins only via bunfig `[serve.static]`, read at process
240
220
  // start - so the boundary plugins (server-fn stubs, server-only emptying) are delivered by
241
221
  // generating a config and re-execing this same command once with `--config=`. The child proves
@@ -255,15 +235,36 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
255
235
  "launches; refusing to serve without the client-boundary plugins.",
256
236
  )
257
237
  }
258
- const { bunfigPath, launchToken } = await writeBunDevConfig(app.cwd)
238
+ const { bunfigPath, launchToken } = await writeBunDevConfig(app.cwd, app.configPath)
259
239
  const child = Bun.spawn(
260
- [process.execPath, `--config=${bunfigPath}`, ...process.argv.slice(1)],
240
+ [
241
+ process.execPath,
242
+ `--config=${bunfigPath}`,
243
+ // The app's `conditions` reach SSR here or nowhere: bunfig has no key for them (a top-level,
244
+ // `[run]`, `[serve.static]` or `[bundle]` `conditions` is parsed and ignored), and the re-exec
245
+ // is the only point where this process's resolver is still configurable. Without it a package
246
+ // with an `exports` map resolves to a DIFFERENT file on the server than in the client bundle,
247
+ // which is the two-toolchains-one-module failure the pipeline guard exists to prevent.
248
+ //
249
+ // One flag per condition. `--conditions=a,b` is accepted and matches nothing: Bun takes the
250
+ // whole string as a single condition name, so the comma form fails silently.
251
+ ...(app.framework.conditions ?? []).map((condition) => `--conditions=${condition}`),
252
+ ...process.argv.slice(1),
253
+ ],
261
254
  {
262
255
  stdio: ["inherit", "inherit", "inherit"],
263
256
  env: {
264
257
  ...process.env,
265
258
  NIFRA_BUN_DEV_TOKEN: launchToken,
266
259
  NIFRA_BUN_DEV_DEPTH: String(depth + 1),
260
+ // The child is the process that renders, so it is the one that needs dev-shaped runtimes.
261
+ // A library ships its dev build behind the `development` export condition with a `NODE_ENV`
262
+ // fallback for resolvers that set no conditions - which is Bun's runtime, where SSR runs,
263
+ // so on the server that fallback is the only signal there is. It has to be in place before
264
+ // the app's config is read, because reading it imports the adapter and through it the
265
+ // framework runtime; setting it inside the dev server would already be a step too late.
266
+ // Preserved if the caller pinned one.
267
+ NODE_ENV: process.env.NODE_ENV ?? "development",
267
268
  },
268
269
  },
269
270
  )
@@ -272,6 +273,27 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
272
273
  process.on("SIGTERM", forward)
273
274
  process.exit(await child.exited)
274
275
  }
276
+ // SSR runs in THIS process, on Bun's runtime - a different loader from the dev-server bundler the
277
+ // bunfig above configures. Both halves have to agree, so the runtime gets the same transforms:
278
+ //
279
+ // - CSS Modules. The scoped name is a pure function of file path + class name, so the `"ssr"` form
280
+ // produces the identical map the client bundle emits (and no stylesheet - that ships from the
281
+ // client build). Without it `styles.box` is `undefined` on the server: the SSR markup carries no
282
+ // class, the page paints unstyled, and hydration then reports a className mismatch.
283
+ // - the app's `serverPlugins` - the SSR counterpart of the `clientPlugins` the generated bunfig
284
+ // delivers, exactly as `nifra build`'s prerender pass registers them.
285
+ //
286
+ // Registered BEFORE `createDevServer`, because a Bun runtime plugin only affects modules loaded
287
+ // after it - and `createApp` imports the route modules.
288
+ const { plugin } = await import("bun")
289
+ const { cssModulesBunPlugin } = await import("@nifrajs/web/plugins/css-modules")
290
+ plugin(cssModulesBunPlugin("ssr"))
291
+ for (const p of asBunPlugins(app.resolvedPlugins.serverPlugins)) plugin(p)
292
+ // "After" has one consequence an adapter has to respect. Reading the app's config is what PRODUCES
293
+ // these plugins, so the config's import graph - which includes the adapter - is already loaded when
294
+ // they register. An adapter must therefore not import an asset its own plugin compiles at module
295
+ // scope; it has to defer to first render, which happens after this point. `@nifrajs/web-svelte`
296
+ // loads its `Chain.svelte` that way for exactly this reason, and says so at the import.
275
297
  const { createDevServer } = await import("@nifrajs/web/dev")
276
298
  const { framework: fw, routesDir, outDir, backend } = app
277
299
  const server = await createDevServer({
@@ -279,8 +301,10 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
279
301
  outDir,
280
302
  clientModule: fw.clientModule,
281
303
  port: flags.port,
282
- // The Bun pipeline's own plugins (SFC compilers etc.) - never `vitePlugins`, which belong to the
283
- // other pipeline; `assertPipelineSeparation` already refuses a plugin sitting in the wrong slot.
304
+ // For the background client-leak guard, which re-runs `buildClient` on change - the SERVED client
305
+ // bundle gets these same plugins through the generated bunfig, since Bun's dev-server bundler takes
306
+ // plugins only as module paths. Never `vitePlugins`, which belong to the other pipeline;
307
+ // `assertPipelineSeparation` already refuses a plugin sitting in the wrong slot.
284
308
  plugins: asBunPlugins(app.resolvedPlugins.clientPlugins),
285
309
  ...(fw.publicDir !== undefined ? { publicDir: fw.publicDir } : {}),
286
310
  ...(fw.conditions ? { conditions: fw.conditions } : {}),
@@ -294,9 +318,10 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
294
318
  }),
295
319
  })
296
320
  console.log(`nifra dev (bun) → http://localhost:${server.port}`)
321
+ console.log(` ${describePipeline(decision)}`)
297
322
  return
298
323
  }
299
- // Preflight: `nifra dev` needs `vite` resolvable from the project. Run via `bunx @nifrajs/cli dev` the CLI
324
+ // Preflight: the Vite fallback needs `vite` resolvable from the project. Run via `bunx @nifrajs/cli dev` the CLI
300
325
  // sits in an isolated install where the project's peer deps don't resolve, so the vite import below fails
301
326
  // with an opaque ERR_MODULE_NOT_FOUND. Surface the real fix instead.
302
327
  try {
@@ -317,6 +342,18 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
317
342
  // same file in one process, with only one of them governed by Vite's `resolve.dedupe`. That is what
318
343
  // made an app's hand-written React alias fail to reach SSR, and it is the condition being removed -
319
344
  // not a redundancy worth keeping "just in case".
345
+ //
346
+ // Svelte is the case that proves the rule, and the discarded attempts are recorded so they are not
347
+ // retried. The adapter package stays `ssr.external` (its context must be the ONE instance the
348
+ // Bun-imported adapter provides), so BUN loads it - and `@nifrajs/web-svelte` renders through its own
349
+ // `Chain.svelte`, which needs a compiler that this pipeline does not put in the runtime. Registering
350
+ // the app's `serverPlugins` here, scoped to that package's directory, compiles it against a SECOND
351
+ // Svelte runtime and the first `setContext` dies (`context.function` null); externalizing the
352
+ // adapter's framework peer as well gets further and then dies on `context.function[FILENAME]`,
353
+ // because `svelteBunPlugin` and `vite-plugin-svelte` do not emit the same dev-mode component shape.
354
+ // Both attempts add a compiler. The fix removes one instead: `createViteDevServer` publishes its
355
+ // `ssrLoadModule` via `setSsrModuleLoader`, and the adapter loads `Chain.svelte` AND `svelte/server`
356
+ // through it - one toolchain, one runtime, context intact across the whole tree.
320
357
  const server = await createViteDevServer({
321
358
  root: cwd,
322
359
  routesDir,
@@ -339,7 +376,8 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
339
376
  ...apiOf(backend),
340
377
  }),
341
378
  })
342
- console.log(`nifra dev → http://localhost:${server.port}`)
379
+ console.log(`nifra dev (vite) → http://localhost:${server.port}`)
380
+ console.log(` ${describePipeline(decision)}`)
343
381
  }
344
382
 
345
383
  /**
@@ -407,10 +445,10 @@ async function buildForTarget(app: LoadedApp, target: string, flags: Flags): Pro
407
445
  // The static target needs a built app to drive prerendering - only build it when targeting static.
408
446
  ...(target === "static" ? { prerenderApp: await buildPrerenderApp(app) } : {}),
409
447
  })
410
- console.log(`nifra build (${target}${useVite ? ", vite" : ""}) → ${result.run}`)
411
- // An auto-selected pipeline is stated, never silent: the user asked for the default and got something
412
- // else, and the reason is the thing that makes that legible instead of surprising.
413
- if (decision.reason !== undefined) console.log(` ${decision.reason}`)
448
+ console.log(`nifra build (${target}, ${decision.pipeline}) → ${result.run}`)
449
+ // The pipeline is stated on every build, not only the surprising ones - an auto-selected Vite build
450
+ // must never look like the default, and a default Bun build must not leave the question open either.
451
+ console.log(` ${describePipeline(decision)}`)
414
452
  if (flags.report) console.log(`\n${renderSizeReport(result.size)}`)
415
453
  }
416
454
 
@@ -619,6 +657,30 @@ async function main(): Promise<void> {
619
657
  }
620
658
  return
621
659
  }
660
+ if (command === "sdk") {
661
+ const languageIndex = argv.indexOf("--lang")
662
+ const language = languageIndex === -1 ? undefined : argv[languageIndex + 1]
663
+ const outIndex = argv.indexOf("--out")
664
+ const out = outIndex === -1 ? undefined : argv[outIndex + 1]
665
+ if (language !== "python" && language !== "go") {
666
+ console.error("[nifra] sdk needs --lang python or --lang go")
667
+ process.exitCode = 1
668
+ return
669
+ }
670
+ if (outIndex !== -1 && (out === undefined || out.startsWith("-"))) {
671
+ console.error("[nifra] --out needs a file path")
672
+ process.exitCode = 1
673
+ return
674
+ }
675
+ try {
676
+ const { runSdk } = await import("./sdk.ts")
677
+ await runSdk(process.cwd(), { language, ...(out !== undefined ? { out } : {}) })
678
+ } catch (err) {
679
+ console.error(formatCliError(err))
680
+ process.exitCode = 1
681
+ }
682
+ return
683
+ }
622
684
  // Route assurance intentionally loads only its explicit config (which imports the backend it audits),
623
685
  // not the full SSR app. Like snapshot/diff, this works for API-only projects.
624
686
  if (command === "assure") {
@@ -823,7 +885,9 @@ async function main(): Promise<void> {
823
885
  else if (command === "build") await buildForTarget(app, flags.target, flags)
824
886
  else if (command === "context") console.log(describeProject(app))
825
887
  else if (command === "routes") {
826
- if (argv.includes("--modes")) {
888
+ if (argv.includes("--graph")) {
889
+ console.log(await describeRouteGraph(app, { json: argv.includes("--json") }))
890
+ } else if (argv.includes("--modes")) {
827
891
  // `--modes` answers a different question than the default table: not "what URLs exist" but "how
828
892
  // does each render, and can the target I'm about to deploy to actually honour that". Resolves
829
893
  // against `--target` when given, so a `static` build's server-needing route fails here, in one
@@ -7,10 +7,15 @@
7
7
  * would ship to the browser WHOLE (body, DB handles, secrets) instead of as the RPC stub the
8
8
  * production client build emits, and a `*.server` module would ship instead of being emptied.
9
9
  *
10
+ * The same channel is the only way an app's OWN `clientPlugins` (SFC compilers and the like) can reach
11
+ * that bundler: the CLI holds them as plugin OBJECTS, and bunfig accepts only module paths - so the
12
+ * generated module re-imports the app's config and composes them in.
13
+ *
10
14
  * So the CLI generates, under `.nifra/dev-bun/`:
11
15
  * - `boundary-plugin.ts` - default-exports one plugin composing the SAME `serverFnStubPlugin` +
12
- * `serverOnlyEmptyPlugin` the production build uses (imported from `@nifrajs/web/build`, resolved
13
- * from the app - identical stubs, not a re-implementation);
16
+ * `serverOnlyEmptyPlugin` + CSS Modules transform the production build uses (imported from
17
+ * `@nifrajs/web`, resolved from the app - identical transforms, not re-implementations), plus the
18
+ * app's own `clientPlugins`;
14
19
  * - `bunfig.toml` - the app's ENTIRE bunfig carried over verbatim (jsx, loaders, defines, install
15
20
  * settings - dropping any of them would give dev a different Bun than the app configured), with
16
21
  * `[serve.static] plugins` merged to put the boundary plugin first and path-bearing entries
@@ -34,7 +39,7 @@
34
39
  */
35
40
  import { timingSafeEqual } from "node:crypto"
36
41
  import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
37
- import { resolve } from "node:path"
42
+ import { relative, resolve } from "node:path"
38
43
 
39
44
  /** TOML string literal: basic (double-quoted) form with the two escapes it needs. */
40
45
  function tomlString(value: string): string {
@@ -132,22 +137,56 @@ export function renderDevBunfig(
132
137
  )
133
138
  }
134
139
 
135
- /** The generated plugin module: compose the two production boundary plugins into one default
136
- * export. Import specifiers are bare so they resolve from the APP's install (the module lives
137
- * under the app's `.nifra/`), keeping dev stubs byte-identical to production's. */
138
- export function renderBoundaryPluginModule(): string {
139
- return `// Generated by \`nifra dev --bun\` - do not edit. Regenerated on every dev start.
140
- import { serverFnStubPlugin, serverOnlyEmptyPlugin } from "@nifrajs/web/build"
140
+ /** A TS/JS import specifier for `target` as seen from `fromDir`, always `./`-prefixed and posix-slashed
141
+ * so it reads as a FILE path to the module loader rather than a package name. */
142
+ function relativeSpecifier(fromDir: string, target: string): string {
143
+ const rel = relative(fromDir, target).replaceAll("\\", "/")
144
+ return rel.startsWith(".") ? rel : `./${rel}`
145
+ }
141
146
 
147
+ /**
148
+ * The generated plugin module: compose the production boundary plugins, the production CSS Modules
149
+ * transform, and the APP's own `clientPlugins` into one default export.
150
+ *
151
+ * The `@nifrajs/*` specifiers are bare so they resolve from the APP's install (the module lives under
152
+ * the app's `.nifra/`), keeping dev transforms byte-identical to production's.
153
+ *
154
+ * `configPath` is the app's `nifra.config.ts` (or `framework.ts`). Importing it here is the only way an
155
+ * app's `clientPlugins` can reach Bun's dev-server bundler at all: that bundler takes plugins as module
156
+ * PATHS via bunfig, never as the plugin objects the CLI already holds in memory. Without this an app
157
+ * whose transforms live in `clientPlugins` had no working Bun dev loop - its client would be bundled
158
+ * with the transform silently missing. `setup` is async because `clientPlugins` may be a thunk.
159
+ */
160
+ export function renderBoundaryPluginModule(configPath?: string, pluginDir?: string): string {
161
+ const appImport =
162
+ configPath !== undefined && pluginDir !== undefined
163
+ ? `import * as appConfig from ${tomlString(relativeSpecifier(pluginDir, configPath))}\n`
164
+ : ""
165
+ const appSetup =
166
+ appImport === ""
167
+ ? ""
168
+ : ` // The app's OWN client transforms (SFC compilers etc.). \`clientPlugins\` may be a thunk.
169
+ const field = appConfig.clientPlugins
170
+ const appPlugins = (typeof field === "function" ? await field() : field) ?? []
171
+ for (const p of appPlugins) await p.setup(build)
172
+ `
173
+ return `// Generated by \`nifra dev\` (Bun pipeline) - do not edit. Regenerated on every dev start.
174
+ import { serverFnStubPlugin, serverOnlyEmptyPlugin } from "@nifrajs/web/build"
175
+ import { cssModulesBunPlugin } from "@nifrajs/web/plugins/css-modules"
176
+ ${appImport}
142
177
  const fn = serverFnStubPlugin()
143
178
  const serverOnly = serverOnlyEmptyPlugin()
179
+ const cssModules = cssModulesBunPlugin("dom")
144
180
 
145
181
  export default {
146
182
  name: "nifra-dev-boundary",
147
- setup(build) {
183
+ async setup(build) {
184
+ // Boundary first: an app plugin must never get to transform a module that the boundary is about to
185
+ // empty or stub, or the transform decides what ships instead of the boundary.
148
186
  serverOnly.setup(build)
149
187
  fn.setup(build)
150
- },
188
+ cssModules.setup(build)
189
+ ${appSetup} },
151
190
  }
152
191
  `
153
192
  }
@@ -161,12 +200,16 @@ export interface BunDevConfig {
161
200
  const TOKEN_FILE = "launch-token"
162
201
 
163
202
  /** Write the generated config for an app; returns the bunfig path to re-exec with and the fresh
164
- * launch token to pass in the child's environment. */
165
- export async function writeBunDevConfig(appRoot: string): Promise<BunDevConfig> {
203
+ * launch token to pass in the child's environment. `configPath` (the app's `nifra.config.ts` /
204
+ * `framework.ts`) is what lets the app's own `clientPlugins` reach Bun's dev-server bundler. */
205
+ export async function writeBunDevConfig(
206
+ appRoot: string,
207
+ configPath?: string,
208
+ ): Promise<BunDevConfig> {
166
209
  const dir = resolve(appRoot, ".nifra", "dev-bun")
167
210
  mkdirSync(dir, { recursive: true })
168
211
  const pluginPath = resolve(dir, "boundary-plugin.ts")
169
- writeFileSync(pluginPath, renderBoundaryPluginModule())
212
+ writeFileSync(pluginPath, renderBoundaryPluginModule(configPath, dir))
170
213
  const userToml = await Bun.file(resolve(appRoot, "bunfig.toml"))
171
214
  .text()
172
215
  .catch(() => undefined)