@nifrajs/cli 2.10.0 → 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.
- package/README.md +1 -1
- package/dist/check.d.ts +9 -1
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +38 -6
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +2 -18
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +95 -64
- package/dist/cli.js.map +1 -1
- package/dist/dev-bun-config.d.ts +17 -6
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +51 -14
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/doctor.d.ts +10 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +17 -1
- package/dist/doctor.js.map +1 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +7 -1
- package/dist/introspect.js.map +1 -1
- package/dist/load.d.ts +4 -0
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +1 -0
- package/dist/load.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp.js +2 -2
- package/dist/mcp.js.map +1 -1
- package/dist/pipeline-guard.d.ts +25 -10
- package/dist/pipeline-guard.d.ts.map +1 -1
- package/dist/pipeline-guard.js +35 -15
- package/dist/pipeline-guard.js.map +1 -1
- package/dist/pipeline-report.d.ts +34 -0
- package/dist/pipeline-report.d.ts.map +1 -0
- package/dist/pipeline-report.js +282 -0
- package/dist/pipeline-report.js.map +1 -0
- package/docs/llms-full.txt +200 -13
- package/docs/types.json +88 -5
- package/package.json +9 -9
- package/src/check.ts +54 -5
- package/src/cli.ts +99 -65
- package/src/dev-bun-config.ts +57 -14
- package/src/doctor.ts +28 -1
- package/src/introspect.ts +6 -0
- package/src/load.ts +5 -0
- package/src/mcp-http.ts +1 -1
- package/src/mcp.ts +2 -2
- package/src/pipeline-guard.ts +44 -14
- package/src/pipeline-report.ts +341 -0
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
|
|
2174
|
-
//
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
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 (
|
|
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
|
*
|
|
@@ -17,7 +17,7 @@ import { discoverRoutes } from "@nifrajs/web/fs"
|
|
|
17
17
|
import type { BunPlugin } from "bun"
|
|
18
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`:
|
|
34
|
-
*
|
|
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 (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
@@ -152,7 +154,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
|
|
|
152
154
|
\`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
|
|
153
155
|
|
|
154
156
|
// Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
|
|
155
|
-
const CLI_VERSION = "2.
|
|
157
|
+
const CLI_VERSION = "2.11.0"
|
|
156
158
|
|
|
157
159
|
// A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
|
|
158
160
|
const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
|
|
@@ -194,52 +196,26 @@ export function formatCliError(err: unknown): string {
|
|
|
194
196
|
return String(err)
|
|
195
197
|
}
|
|
196
198
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
* Bun's dev server and `Bun.build` are not the same bundler. `Bun.build` transforms `*.module.css` into a
|
|
201
|
-
* scoped class map (the Bun production build of a CSS-Modules app works, verified), but the dev server's
|
|
202
|
-
* bundler does not: the import compiles to a dangling reference and the browser throws
|
|
203
|
-
* `ReferenceError: import_X_module is not defined` from inside the component - a message that names
|
|
204
|
-
* neither CSS Modules nor the dev server, so the cause is invisible.
|
|
205
|
-
*
|
|
206
|
-
* A silently-broken client is the one outcome worth failing the command over, so this checks up front and
|
|
207
|
-
* names the trade plus both ways out. Narrow by design: only the transform proven missing is refused, so
|
|
208
|
-
* an app without CSS Modules still gets the Bun dev loop.
|
|
209
|
-
*/
|
|
210
|
-
export async function assertBunDevSupportsApp(app: LoadedApp): Promise<void> {
|
|
211
|
-
// Server functions and `*.server` modules are handled: the generated bunfig (dev-bun-config.ts)
|
|
212
|
-
// delivers the SAME serverFnStubPlugin/serverOnlyEmptyPlugin the production build uses to Bun's
|
|
213
|
-
// dev-server bundler via `[serve.static] plugins` - the one channel that bundler accepts plugins
|
|
214
|
-
// through (a runtime `Bun.plugin` never reaches it; programmatic ask: oven-sh/bun#36830). The old
|
|
215
|
-
// fail-closed refusal for those modules is gone because the leak it guarded is gone.
|
|
216
|
-
//
|
|
217
|
-
// CSS Modules remain gated: the `*.module.css` plugin rests on an `onResolve` virtual-module
|
|
218
|
-
// stylesheet emitter that is not yet verified under `[serve.static]`, and its failure mode is a
|
|
219
|
-
// broken client rather than a leak - refuse loudly until it is proven there.
|
|
220
|
-
const { Glob } = await import("bun")
|
|
221
|
-
const offenders: string[] = []
|
|
222
|
-
for await (const file of new Glob("**/*.module.css").scan({ cwd: app.cwd, dot: false })) {
|
|
223
|
-
if (/(^|\/)(node_modules|dist|\.nifra|\.git)\//.test(file)) continue
|
|
224
|
-
offenders.push(file)
|
|
225
|
-
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.")
|
|
226
202
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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",
|
|
234
217
|
)
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
async function dev(app: LoadedApp, flags: Flags): Promise<void> {
|
|
238
|
-
// `--bun`: the Bun-pipeline dev server - Bun.serve's native HMR bundles + hot-reloads the client while
|
|
239
|
-
// Bun's runtime resolves SSR, with no Vite in the process at all. The mirror of `nifra build --vite`:
|
|
240
|
-
// each pipeline is selectable in BOTH phases, and neither ever runs inside the other.
|
|
241
|
-
if (flags.bun) {
|
|
242
|
-
await assertBunDevSupportsApp(app)
|
|
218
|
+
if (decision.pipeline === "bun") {
|
|
243
219
|
// Bun's dev-server bundler takes plugins only via bunfig `[serve.static]`, read at process
|
|
244
220
|
// start - so the boundary plugins (server-fn stubs, server-only emptying) are delivered by
|
|
245
221
|
// generating a config and re-execing this same command once with `--config=`. The child proves
|
|
@@ -259,15 +235,36 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
|
|
|
259
235
|
"launches; refusing to serve without the client-boundary plugins.",
|
|
260
236
|
)
|
|
261
237
|
}
|
|
262
|
-
const { bunfigPath, launchToken } = await writeBunDevConfig(app.cwd)
|
|
238
|
+
const { bunfigPath, launchToken } = await writeBunDevConfig(app.cwd, app.configPath)
|
|
263
239
|
const child = Bun.spawn(
|
|
264
|
-
[
|
|
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
|
+
],
|
|
265
254
|
{
|
|
266
255
|
stdio: ["inherit", "inherit", "inherit"],
|
|
267
256
|
env: {
|
|
268
257
|
...process.env,
|
|
269
258
|
NIFRA_BUN_DEV_TOKEN: launchToken,
|
|
270
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",
|
|
271
268
|
},
|
|
272
269
|
},
|
|
273
270
|
)
|
|
@@ -276,6 +273,27 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
|
|
|
276
273
|
process.on("SIGTERM", forward)
|
|
277
274
|
process.exit(await child.exited)
|
|
278
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.
|
|
279
297
|
const { createDevServer } = await import("@nifrajs/web/dev")
|
|
280
298
|
const { framework: fw, routesDir, outDir, backend } = app
|
|
281
299
|
const server = await createDevServer({
|
|
@@ -283,8 +301,10 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
|
|
|
283
301
|
outDir,
|
|
284
302
|
clientModule: fw.clientModule,
|
|
285
303
|
port: flags.port,
|
|
286
|
-
//
|
|
287
|
-
//
|
|
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.
|
|
288
308
|
plugins: asBunPlugins(app.resolvedPlugins.clientPlugins),
|
|
289
309
|
...(fw.publicDir !== undefined ? { publicDir: fw.publicDir } : {}),
|
|
290
310
|
...(fw.conditions ? { conditions: fw.conditions } : {}),
|
|
@@ -298,9 +318,10 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
|
|
|
298
318
|
}),
|
|
299
319
|
})
|
|
300
320
|
console.log(`nifra dev (bun) → http://localhost:${server.port}`)
|
|
321
|
+
console.log(` ${describePipeline(decision)}`)
|
|
301
322
|
return
|
|
302
323
|
}
|
|
303
|
-
// Preflight:
|
|
324
|
+
// Preflight: the Vite fallback needs `vite` resolvable from the project. Run via `bunx @nifrajs/cli dev` the CLI
|
|
304
325
|
// sits in an isolated install where the project's peer deps don't resolve, so the vite import below fails
|
|
305
326
|
// with an opaque ERR_MODULE_NOT_FOUND. Surface the real fix instead.
|
|
306
327
|
try {
|
|
@@ -321,6 +342,18 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
|
|
|
321
342
|
// same file in one process, with only one of them governed by Vite's `resolve.dedupe`. That is what
|
|
322
343
|
// made an app's hand-written React alias fail to reach SSR, and it is the condition being removed -
|
|
323
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.
|
|
324
357
|
const server = await createViteDevServer({
|
|
325
358
|
root: cwd,
|
|
326
359
|
routesDir,
|
|
@@ -343,7 +376,8 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
|
|
|
343
376
|
...apiOf(backend),
|
|
344
377
|
}),
|
|
345
378
|
})
|
|
346
|
-
console.log(`nifra dev → http://localhost:${server.port}`)
|
|
379
|
+
console.log(`nifra dev (vite) → http://localhost:${server.port}`)
|
|
380
|
+
console.log(` ${describePipeline(decision)}`)
|
|
347
381
|
}
|
|
348
382
|
|
|
349
383
|
/**
|
|
@@ -411,10 +445,10 @@ async function buildForTarget(app: LoadedApp, target: string, flags: Flags): Pro
|
|
|
411
445
|
// The static target needs a built app to drive prerendering - only build it when targeting static.
|
|
412
446
|
...(target === "static" ? { prerenderApp: await buildPrerenderApp(app) } : {}),
|
|
413
447
|
})
|
|
414
|
-
console.log(`nifra build (${target}${
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
|
|
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)}`)
|
|
418
452
|
if (flags.report) console.log(`\n${renderSizeReport(result.size)}`)
|
|
419
453
|
}
|
|
420
454
|
|
package/src/dev-bun-config.ts
CHANGED
|
@@ -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
|
|
13
|
-
* from the app - identical
|
|
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
|
-
/**
|
|
136
|
-
*
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return
|
|
140
|
-
|
|
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
|
-
|
|
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)
|
package/src/doctor.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { readdir, realpath, stat } from "node:fs/promises"
|
|
|
21
21
|
import { builtinModules } from "node:module"
|
|
22
22
|
import { dirname, join, relative, sep } from "node:path"
|
|
23
23
|
import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
|
|
24
|
+
import { collectPipelineReport, type PipelineReport } from "./pipeline-report.ts"
|
|
24
25
|
|
|
25
26
|
// Runtime-provided modules that are never an npm dependency: Node core (bare + `node:` form) and Bun's
|
|
26
27
|
// own `bun` module. `node:`/`bun:`-prefixed specifiers are filtered in packageOf by prefix.
|
|
@@ -135,6 +136,15 @@ export interface DoctorResult {
|
|
|
135
136
|
* Advisory (never folded into `ok`): while actively editing a linked package its dist is always
|
|
136
137
|
* momentarily behind - the finding matters when a dev server starts against it. */
|
|
137
138
|
readonly staleDists: readonly StaleDistFinding[]
|
|
139
|
+
/**
|
|
140
|
+
* Which bundler this app's `dev`/`build` phases run on, read statically, plus the config hazards
|
|
141
|
+
* that exist only because there are two. Absent when the directory is not a nifra app.
|
|
142
|
+
*
|
|
143
|
+
* Reported by doctor rather than only by `dev`/`build` because the answer governs how the rest of a
|
|
144
|
+
* project is read - which plugin slot is live, whether `conditions` reach the client bundle, which
|
|
145
|
+
* toolchain compiles a component - and asking for it should not require starting a server.
|
|
146
|
+
*/
|
|
147
|
+
readonly pipeline?: PipelineReport
|
|
138
148
|
/** Dependencies written by `--auto-fix` / MCP `autoFix:true`. */
|
|
139
149
|
readonly fixed?: readonly DoctorAppliedFix[]
|
|
140
150
|
/** Findings that were safe to report but not safe to write automatically. */
|
|
@@ -701,13 +711,16 @@ export async function collectDoctorResult(cwd: string): Promise<DoctorResult> {
|
|
|
701
711
|
findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)
|
|
702
712
|
const duplicateInstalls = await collectDuplicateInstalls(cwd, pkg)
|
|
703
713
|
const staleDists = await collectStaleWorkspaceDists(cwd, pkg)
|
|
714
|
+
const pipeline = await collectPipelineReport(cwd)
|
|
715
|
+
const pipelineErrors = pipeline.findings.filter((f) => f.severity === "error")
|
|
704
716
|
// `staleDists` is advisory (see DoctorResult): it never fails `ok`.
|
|
705
717
|
return {
|
|
706
|
-
ok: findings.length === 0 && duplicateInstalls.length === 0,
|
|
718
|
+
ok: findings.length === 0 && duplicateInstalls.length === 0 && pipelineErrors.length === 0,
|
|
707
719
|
ran: true,
|
|
708
720
|
findings,
|
|
709
721
|
duplicateInstalls,
|
|
710
722
|
staleDists,
|
|
723
|
+
...(pipeline.ran ? { pipeline } : {}),
|
|
711
724
|
}
|
|
712
725
|
}
|
|
713
726
|
|
|
@@ -811,6 +824,20 @@ export async function runDoctor(
|
|
|
811
824
|
}
|
|
812
825
|
console.log("")
|
|
813
826
|
}
|
|
827
|
+
// Printed before the findings and regardless of ok: which bundler owns this app is the frame the
|
|
828
|
+
// rest of the report is read in, not a footnote to it.
|
|
829
|
+
if (result.pipeline !== undefined) {
|
|
830
|
+
const p = result.pipeline
|
|
831
|
+
console.log(
|
|
832
|
+
p.pipeline === "unknown"
|
|
833
|
+
? `• bundler: could not be read from ${p.configFile} - ${p.reason}\n`
|
|
834
|
+
: `• bundler: ${p.pipeline} (${p.reason})\n`,
|
|
835
|
+
)
|
|
836
|
+
for (const f of p.findings) {
|
|
837
|
+
console.log(`${f.severity === "error" ? "✗" : "⚠"} ${f.file}:${f.line ?? 0} ${f.message}`)
|
|
838
|
+
console.log(` fix: ${f.fix}\n`)
|
|
839
|
+
}
|
|
840
|
+
}
|
|
814
841
|
// Advisory, printed regardless of ok: a stale linked dist doesn't fail doctor, but silently eating
|
|
815
842
|
// it costs an hour of misdiagnosis when the dev server 500s inside the package.
|
|
816
843
|
if (result.staleDists.length > 0) {
|
package/src/introspect.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { type ReflectedRoute, reflectRoutes, type SchemaReflection } from "@nifr
|
|
|
9
9
|
import type { Manifest } from "@nifrajs/web"
|
|
10
10
|
import { discoverRoutes } from "@nifrajs/web/fs"
|
|
11
11
|
import type { LoadedApp } from "./load.ts"
|
|
12
|
+
import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
|
|
12
13
|
|
|
13
14
|
/** Read the backend's registered routes, if it's a `server()` with a `.routes()` method. */
|
|
14
15
|
export function backendRoutes(backend: unknown): ReflectedRoute[] {
|
|
@@ -232,6 +233,11 @@ export function describeProject(app: LoadedApp, filter?: ContextFilter): string
|
|
|
232
233
|
// Point agents at the verified surface, not recalled APIs - nifra is young, so training data is the
|
|
233
234
|
// wrong source. The MCP tools (nifra_docs / nifra_example) are checked against the installed version.
|
|
234
235
|
"> **Build against THIS surface + the verified tools - do not write nifra APIs from memory** (the\n> framework is young; recalled APIs drift). For framework code, call `nifra_example` (snippets\n> typechecked against the installed version) or `nifra_docs`; verify edits with `nifra_run` + `nifra_check`.",
|
|
236
|
+
// The authoritative answer, not the static reading `nifra_check`/`nifra_doctor` give: this runs on
|
|
237
|
+
// the LOADED config, so it is the same decision `nifra dev` and `nifra build` make. Stated in the
|
|
238
|
+
// index because it governs where a plugin goes and which toolchain compiles a component - an edit
|
|
239
|
+
// put in the other pipeline's slot is accepted and then silently never runs.
|
|
240
|
+
`**Bundler**: ${describePipeline(chooseBuildPipeline(app.resolvedPlugins))}. \`vitePlugins\` feed Vite; \`clientPlugins\`/\`serverPlugins\` feed Bun.build. Both phases (dev and build) use the same one.`,
|
|
235
241
|
)
|
|
236
242
|
}
|
|
237
243
|
if (filter?.kind !== "pages") {
|
package/src/load.ts
CHANGED
|
@@ -93,6 +93,10 @@ export interface ResolvedPlugins {
|
|
|
93
93
|
|
|
94
94
|
export interface LoadedApp {
|
|
95
95
|
readonly cwd: string
|
|
96
|
+
/** Absolute path of the config this app was loaded from - `nifra.config.ts`, or `framework.ts` as the
|
|
97
|
+
* fallback. `nifra dev`'s Bun pipeline re-imports it from a GENERATED module so the app's own
|
|
98
|
+
* `clientPlugins` reach Bun's dev-server bundler, which takes plugins only as module paths. */
|
|
99
|
+
readonly configPath: string
|
|
96
100
|
readonly routesDir: string
|
|
97
101
|
/** Build output dir (also where `nifra start` reads `manifest.json` + serves `/assets/*`). */
|
|
98
102
|
readonly outDir: string
|
|
@@ -222,6 +226,7 @@ export async function loadApp(
|
|
|
222
226
|
|
|
223
227
|
return {
|
|
224
228
|
cwd,
|
|
229
|
+
configPath,
|
|
225
230
|
routesDir: resolve(cwd, "routes"),
|
|
226
231
|
outDir: resolve(cwd, outDirName),
|
|
227
232
|
framework: { ...fw, clientModule } as NifraFramework,
|
package/src/mcp-http.ts
CHANGED
|
@@ -29,7 +29,7 @@ export { docsTools } from "./mcp-docs-tools.ts"
|
|
|
29
29
|
export type { TypeEntry } from "./types-search.ts"
|
|
30
30
|
|
|
31
31
|
// Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
|
|
32
|
-
const VERSION = "2.
|
|
32
|
+
const VERSION = "2.11.0"
|
|
33
33
|
const SERVER_INFO = { name: "nifra-docs", version: VERSION }
|
|
34
34
|
// Derive the GET/health tool list from the tools actually served, so the line can never drift from them.
|
|
35
35
|
const docsHealth = (tools: McpTool[]): string =>
|