@nifrajs/cli 2.1.0 → 2.2.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/dist/check.d.ts +4 -1
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +71 -0
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +165 -23
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts +5 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +66 -6
- package/dist/doctor.js.map +1 -1
- package/dist/load.d.ts +11 -0
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +30 -0
- package/dist/load.js.map +1 -1
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +7 -3
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-render.js +9 -1
- package/dist/mcp-render.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +44 -2
- package/dist/mcp.js.map +1 -1
- package/dist/pipeline-guard.d.ts +83 -0
- package/dist/pipeline-guard.d.ts.map +1 -0
- package/dist/pipeline-guard.js +136 -0
- package/dist/pipeline-guard.js.map +1 -0
- package/dist/types-search.d.ts +1 -1
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +47 -2
- package/dist/types-search.js.map +1 -1
- package/docs/examples.json +6 -0
- package/docs/llms-full.txt +81 -30
- package/docs/types.json +613 -36
- package/package.json +9 -9
- package/src/check.ts +91 -0
- package/src/cli.ts +173 -24
- package/src/doctor.ts +71 -6
- package/src/load.ts +44 -0
- package/src/mcp-docs-tools.ts +13 -3
- package/src/mcp-http.ts +1 -1
- package/src/mcp-render.ts +9 -1
- package/src/mcp.ts +45 -2
- package/src/pipeline-guard.ts +186 -0
- package/src/types-search.ts +50 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.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.
|
|
45
|
-
"@nifrajs/core": "2.
|
|
46
|
-
"@nifrajs/mcp": "2.
|
|
47
|
-
"@nifrajs/runner": "2.
|
|
48
|
-
"@nifrajs/schema": "2.
|
|
49
|
-
"@nifrajs/testing": "2.
|
|
50
|
-
"@nifrajs/web": "2.
|
|
51
|
-
"create-nifra": "2.
|
|
44
|
+
"@nifrajs/client": "2.2.0",
|
|
45
|
+
"@nifrajs/core": "2.2.0",
|
|
46
|
+
"@nifrajs/mcp": "2.2.0",
|
|
47
|
+
"@nifrajs/runner": "2.2.0",
|
|
48
|
+
"@nifrajs/schema": "2.2.0",
|
|
49
|
+
"@nifrajs/testing": "2.2.0",
|
|
50
|
+
"@nifrajs/web": "2.2.0",
|
|
51
|
+
"create-nifra": "2.2.0"
|
|
52
52
|
},
|
|
53
53
|
"repository": {
|
|
54
54
|
"type": "git",
|
package/src/check.ts
CHANGED
|
@@ -471,6 +471,69 @@ export function scanUntypedClient(file: string, content: string): SourceFinding[
|
|
|
471
471
|
return out
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
+
/**
|
|
475
|
+
* Specifiers that no longer exist, and what replaced them.
|
|
476
|
+
*
|
|
477
|
+
* These are the breaks a typecheck does not catch and a test suite does not either. `@nifrajs/core/ws`
|
|
478
|
+
* was a side-effect import removed in 2.0 in favour of `.use(websocket())`; a consuming package kept
|
|
479
|
+
* the import and a 533-test suite stayed green while the app could not boot. `@nifrajs/budget` folded
|
|
480
|
+
* into core with no npm deprecation, so `bun install` fails workspace-wide with a resolution error
|
|
481
|
+
* that names neither the cause nor the replacement.
|
|
482
|
+
*
|
|
483
|
+
* A lint is the right shape for this: it runs before boot, names the file and line, and states the
|
|
484
|
+
* replacement - which is what neither the resolver error nor the runtime failure does.
|
|
485
|
+
*/
|
|
486
|
+
const REMOVED_IMPORTS: ReadonlyArray<{
|
|
487
|
+
readonly specifier: string
|
|
488
|
+
readonly since: string
|
|
489
|
+
readonly replacement: string
|
|
490
|
+
/** Flag ONLY the bare side-effect form (`import "x"`). A module that still exports values is not
|
|
491
|
+
* removed - only its install-by-import behaviour is - so flagging every import of it would be
|
|
492
|
+
* wrong and would train people to ignore the rule. */
|
|
493
|
+
readonly sideEffectOnly?: boolean
|
|
494
|
+
}> = [
|
|
495
|
+
{
|
|
496
|
+
specifier: "@nifrajs/budget",
|
|
497
|
+
since: "2.0",
|
|
498
|
+
replacement:
|
|
499
|
+
'import from "@nifrajs/core/budget" — the package folded into core and npm `latest` is still 1.13.0, so a `^2` range resolves to nothing',
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
specifier: "@nifrajs/core/ws",
|
|
503
|
+
since: "2.0",
|
|
504
|
+
sideEffectOnly: true,
|
|
505
|
+
replacement:
|
|
506
|
+
'a bare `import "@nifrajs/core/ws"` no longer installs the runtime — register it explicitly with `.use(websocket())`, importing `websocket` from the same module',
|
|
507
|
+
},
|
|
508
|
+
]
|
|
509
|
+
|
|
510
|
+
/** Whether the import statement at `index` is the bare side-effect form (`import "x"`), rather than
|
|
511
|
+
* one with bindings (`import { y } from "x"`). */
|
|
512
|
+
function isSideEffectImport(content: string, index: number): boolean {
|
|
513
|
+
// `staticImportEdges` reports the index of the STATEMENT, not of the specifier, so read forward:
|
|
514
|
+
// a side-effect import is `import` followed directly by the quoted specifier, with no bindings and
|
|
515
|
+
// no `from` in between.
|
|
516
|
+
return /^import\s*["'`]/.test(content.slice(index, index + 32))
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Flag an import of a specifier that no longer exists. Pure; matches the exact specifier or a subpath
|
|
520
|
+
* of it, so `@nifrajs/budget/x` is caught alongside `@nifrajs/budget`. */
|
|
521
|
+
export function scanRemovedImports(file: string, content: string): SourceFinding[] {
|
|
522
|
+
const out: SourceFinding[] = []
|
|
523
|
+
const lines = content.split("\n")
|
|
524
|
+
for (const edge of staticImportEdges(content)) {
|
|
525
|
+
const removed = REMOVED_IMPORTS.find(
|
|
526
|
+
(entry) =>
|
|
527
|
+
edge.specifier === entry.specifier || edge.specifier.startsWith(`${entry.specifier}/`),
|
|
528
|
+
)
|
|
529
|
+
if (removed === undefined) continue
|
|
530
|
+
if (removed.sideEffectOnly === true && !isSideEffectImport(content, edge.index)) continue
|
|
531
|
+
const line = lineAt(content, edge.index)
|
|
532
|
+
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
|
|
533
|
+
}
|
|
534
|
+
return out
|
|
535
|
+
}
|
|
536
|
+
|
|
474
537
|
/** Scan a route module for top-level server-only imports. Returns `[]` for non-route files (only
|
|
475
538
|
* `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Each finding carries
|
|
476
539
|
* the offending `specifier` so the diagnostic can render the `routeFile → specifier` chain. Pure. */
|
|
@@ -939,6 +1002,7 @@ export interface CheckDiagnostic {
|
|
|
939
1002
|
| "typed-client"
|
|
940
1003
|
| "untyped-client"
|
|
941
1004
|
| "server-only-import"
|
|
1005
|
+
| "removed-import"
|
|
942
1006
|
| "response-route"
|
|
943
1007
|
| "undeclared-dependency"
|
|
944
1008
|
| "duplicate-install"
|
|
@@ -1189,6 +1253,7 @@ export async function collectCheckResult(
|
|
|
1189
1253
|
const fetches: SourceFinding[] = []
|
|
1190
1254
|
const staticRoutes: StaticRouteFinding[] = []
|
|
1191
1255
|
const untypedClients: SourceFinding[] = []
|
|
1256
|
+
const removedImports: SourceFinding[] = []
|
|
1192
1257
|
const serverImports: TransitiveServerImportFinding[] = []
|
|
1193
1258
|
const responseRoutes: SourceFinding[] = []
|
|
1194
1259
|
// Route modules (rel + content) collected during the walk, so the TRANSITIVE server-only resolution
|
|
@@ -1207,6 +1272,7 @@ export async function collectCheckResult(
|
|
|
1207
1272
|
fetches.push(...scanFetchText(rel, content, checkConfig.externalMounts))
|
|
1208
1273
|
staticRoutes.push(...scanStaticRouteText(rel, content))
|
|
1209
1274
|
untypedClients.push(...scanUntypedClient(rel, content))
|
|
1275
|
+
removedImports.push(...scanRemovedImports(rel, content))
|
|
1210
1276
|
if (ROUTE_FILE.test(rel)) routeModules.push({ rel, content })
|
|
1211
1277
|
responseRoutes.push(...scanResponseRoutes(rel, content))
|
|
1212
1278
|
}),
|
|
@@ -1299,6 +1365,21 @@ export async function collectCheckResult(
|
|
|
1299
1365
|
suggestion: ownFetchSuggestion(f, routes),
|
|
1300
1366
|
})
|
|
1301
1367
|
}
|
|
1368
|
+
for (const f of removedImports.sort(bySite)) {
|
|
1369
|
+
const entry = REMOVED_IMPORTS.find(
|
|
1370
|
+
(candidate) =>
|
|
1371
|
+
f.snippet.includes(`"${candidate.specifier}`) ||
|
|
1372
|
+
f.snippet.includes(`'${candidate.specifier}`),
|
|
1373
|
+
)
|
|
1374
|
+
diagnostics.push({
|
|
1375
|
+
rule: "removed-import",
|
|
1376
|
+
severity: "error",
|
|
1377
|
+
file: f.file,
|
|
1378
|
+
line: f.line,
|
|
1379
|
+
message: `${f.snippet} — removed in nifra ${entry?.since ?? "2.0"}: ${entry?.replacement ?? "see the changelog"}`,
|
|
1380
|
+
fix: entry?.replacement ?? "see the changelog",
|
|
1381
|
+
})
|
|
1382
|
+
}
|
|
1302
1383
|
for (const f of untypedClients.sort(bySite)) {
|
|
1303
1384
|
diagnostics.push({
|
|
1304
1385
|
rule: "untyped-client",
|
|
@@ -1602,5 +1683,15 @@ export async function runCheck(
|
|
|
1602
1683
|
"\ntip: no .mcp.json here - run `nifra init-agents` to wire this project's MCP + agent files (no-clobber).",
|
|
1603
1684
|
)
|
|
1604
1685
|
}
|
|
1686
|
+
// `public/` used to be served in dev (inherited from Vite) and not in production, so every app
|
|
1687
|
+
// hand-rolled static serving in its own server entry - or shipped a file that 404'd only once
|
|
1688
|
+
// deployed. `@nifrajs/web` owns it now, so an app carrying the workaround can delete it. A tip
|
|
1689
|
+
// rather than a finding: a hand-rolled handler still works (it simply runs first), so this is
|
|
1690
|
+
// not a failure, and telling an app it can delete code is the entire point.
|
|
1691
|
+
if (existsSync(join(cwd, "public"))) {
|
|
1692
|
+
console.log(
|
|
1693
|
+
'\ntip: `public/` is now served by @nifrajs/web in dev AND production (publicDir, default "public"). If your server entry hand-rolls static serving, you can delete it.',
|
|
1694
|
+
)
|
|
1695
|
+
}
|
|
1605
1696
|
return result.ok
|
|
1606
1697
|
}
|
package/src/cli.ts
CHANGED
|
@@ -16,7 +16,8 @@ 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
18
|
import { describeProject, describeRoutes } from "./introspect.ts"
|
|
19
|
-
import { type LoadedApp, loadApp
|
|
19
|
+
import { type LoadedApp, loadApp } from "./load.ts"
|
|
20
|
+
import { chooseBuildPipeline } from "./pipeline-guard.ts"
|
|
20
21
|
|
|
21
22
|
export interface Flags {
|
|
22
23
|
readonly port: number
|
|
@@ -26,13 +27,26 @@ export interface Flags {
|
|
|
26
27
|
readonly target: string
|
|
27
28
|
/** `nifra build --report`: print a per-chunk size + gzip table after the build. */
|
|
28
29
|
readonly report: boolean
|
|
30
|
+
/** `nifra build --vite`: force the client + server through Vite/Rollup. Without a flag the pipeline is
|
|
31
|
+
* chosen per app (`chooseBuildPipeline`) - Bun unless the app's only transforms are `vitePlugins`. */
|
|
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.
|
|
36
|
+
*
|
|
37
|
+
* `nifra build --bun`: force the Bun build. Refuses when that would drop the app's `vitePlugins`. */
|
|
38
|
+
readonly bun: boolean
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
const HELP = `nifra — zero-config dev/build/start for a nifra app
|
|
32
42
|
|
|
33
43
|
Usage:
|
|
34
44
|
nifra dev [--port <n>] [--poll] Start the true-HMR dev server (Vite). Default port ${DEFAULT_DEV_PORT}.
|
|
35
|
-
|
|
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).
|
|
49
|
+
nifra build [--out <dir>] [--report] Emit a complete deploy directory.
|
|
36
50
|
[--target <t>] Target a FULL deploy dir for <t>:
|
|
37
51
|
bun | node | deno | cf-pages | vercel | static. Packages
|
|
38
52
|
buildClient + buildServer (+ prerender for static) so an app
|
|
@@ -40,6 +54,11 @@ Usage:
|
|
|
40
54
|
_routes.json. The server entry is generated from your
|
|
41
55
|
framework.ts (adapter) + backend.ts + routes/. --report prints
|
|
42
56
|
a per-chunk size + gzip table (biggest first).
|
|
57
|
+
[--vite | --bun] Force the bundler. Without a flag it follows your config: Bun
|
|
58
|
+
(faster, Bun-native) unless your ONLY transforms are
|
|
59
|
+
\`vitePlugins\`, which the Bun build cannot run - then Vite,
|
|
60
|
+
and it says so. --bun is refused in exactly that case rather
|
|
61
|
+
than dropping the transforms silently.
|
|
43
62
|
nifra start [--port <n>] [--out <dir>] Run the Bun build at <out>/server.js. Default port ${DEFAULT_DEV_PORT}.
|
|
44
63
|
nifra context Print this project's route INDEX (API + page routes) + conventions
|
|
45
64
|
for an AI agent's prompt. Per-route schemas: nifra mcp's
|
|
@@ -48,6 +67,11 @@ Usage:
|
|
|
48
67
|
routes (routes/) + the in-process backend's API routes,
|
|
49
68
|
marking which API routes are auto-mounted under apiPrefix.
|
|
50
69
|
--json for agents (see POST /api/x is/isn't served, not via 405).
|
|
70
|
+
nifra routes --modes [--target <t>] Show each page route's RENDER MODE (static | isr | ssr),
|
|
71
|
+
hydration, and cache policy in one table. With --target, gate
|
|
72
|
+
against it: a route the target can't honour (an ssr route on a
|
|
73
|
+
static build, ISR where there's no revalidation) exits nonzero
|
|
74
|
+
with the consequence - run in CI so it fails the build, not prod.
|
|
51
75
|
nifra init-agents [--force] [--json] Retrofit an EXISTING app with the agent-discovery files a new
|
|
52
76
|
app ships: .mcp.json + .cursor/mcp.json (register this project's
|
|
53
77
|
nifra MCP), CLAUDE.md (MCP-first preamble + @AGENTS.md import),
|
|
@@ -107,7 +131,7 @@ Usage:
|
|
|
107
131
|
[--ci] [--strict] matrix (in-memory stores, in-process cron/WebSocket, Bun/Deno
|
|
108
132
|
globals, node: builtins) with file:line evidence. --target auto-
|
|
109
133
|
detected from build/deploy scripts, wrangler.toml, or vercel config;
|
|
110
|
-
--ci (or any --target) exits
|
|
134
|
+
--ci (or any --target) exits nonzero when a used feature is
|
|
111
135
|
unsupported on the target; --strict also fails on caveats.
|
|
112
136
|
|
|
113
137
|
Reads nifra.config.ts (adapter + clientModule + plugins; or framework.ts), backend.ts (optional), and
|
|
@@ -117,7 +141,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
|
|
|
117
141
|
\`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
|
|
118
142
|
|
|
119
143
|
// Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
|
|
120
|
-
const CLI_VERSION = "2.
|
|
144
|
+
const CLI_VERSION = "2.2.0"
|
|
121
145
|
|
|
122
146
|
// A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
|
|
123
147
|
const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
|
|
@@ -125,7 +149,67 @@ const asBunPlugins = (v: readonly unknown[]): BunPlugin[] => v as BunPlugin[]
|
|
|
125
149
|
const apiOf = (backend: unknown): { api?: unknown } =>
|
|
126
150
|
backend === undefined ? {} : { api: inProcessClient(backend as never) }
|
|
127
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Refuse `nifra dev --bun` for an app Bun's DEV-server bundler cannot compile.
|
|
154
|
+
*
|
|
155
|
+
* Bun's dev server and `Bun.build` are not the same bundler. `Bun.build` transforms `*.module.css` into a
|
|
156
|
+
* scoped class map (the Bun production build of a CSS-Modules app works, verified), but the dev server's
|
|
157
|
+
* bundler does not: the import compiles to a dangling reference and the browser throws
|
|
158
|
+
* `ReferenceError: import_X_module is not defined` from inside the component — a message that names
|
|
159
|
+
* neither CSS Modules nor the dev server, so the cause is invisible.
|
|
160
|
+
*
|
|
161
|
+
* A silently-broken client is the one outcome worth failing the command over, so this checks up front and
|
|
162
|
+
* names the trade plus both ways out. Narrow by design: only the transform proven missing is refused, so
|
|
163
|
+
* an app without CSS Modules still gets the Bun dev loop.
|
|
164
|
+
*/
|
|
165
|
+
async function assertBunDevSupportsApp(app: LoadedApp): Promise<void> {
|
|
166
|
+
const { Glob } = await import("bun")
|
|
167
|
+
const offenders: string[] = []
|
|
168
|
+
for await (const file of new Glob("**/*.module.css").scan({ cwd: app.cwd, dot: false })) {
|
|
169
|
+
if (/(^|\/)(node_modules|dist|\.nifra|\.git)\//.test(file)) continue
|
|
170
|
+
offenders.push(file)
|
|
171
|
+
if (offenders.length >= 5) break
|
|
172
|
+
}
|
|
173
|
+
if (offenders.length === 0) return
|
|
174
|
+
throw new Error(
|
|
175
|
+
"[nifra] `nifra dev --bun` can't compile CSS Modules: Bun's DEV-server bundler has no `*.module.css` " +
|
|
176
|
+
"transform (its production `Bun.build` does, so `nifra build` is unaffected). The client would load " +
|
|
177
|
+
"with a bare `ReferenceError: import_… is not defined` instead of your component.\n" +
|
|
178
|
+
` CSS Modules found: ${offenders.join(", ")}\n` +
|
|
179
|
+
" Use `nifra dev` (the Vite pipeline handles them), or drop the CSS Modules to use the Bun dev loop.",
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
128
183
|
async function dev(app: LoadedApp, flags: Flags): Promise<void> {
|
|
184
|
+
// `--bun`: the Bun-pipeline dev server — Bun.serve's native HMR bundles + hot-reloads the client while
|
|
185
|
+
// Bun's runtime resolves SSR, with no Vite in the process at all. The mirror of `nifra build --vite`:
|
|
186
|
+
// each pipeline is selectable in BOTH phases, and neither ever runs inside the other.
|
|
187
|
+
if (flags.bun) {
|
|
188
|
+
await assertBunDevSupportsApp(app)
|
|
189
|
+
const { createDevServer } = await import("@nifrajs/web/dev")
|
|
190
|
+
const { framework: fw, routesDir, outDir, backend } = app
|
|
191
|
+
const server = await createDevServer({
|
|
192
|
+
routesDir,
|
|
193
|
+
outDir,
|
|
194
|
+
clientModule: fw.clientModule,
|
|
195
|
+
port: flags.port,
|
|
196
|
+
// The Bun pipeline's own plugins (SFC compilers etc.) — never `vitePlugins`, which belong to the
|
|
197
|
+
// other pipeline; `assertPipelineSeparation` already refuses a plugin sitting in the wrong slot.
|
|
198
|
+
plugins: asBunPlugins(app.resolvedPlugins.clientPlugins),
|
|
199
|
+
...(fw.publicDir !== undefined ? { publicDir: fw.publicDir } : {}),
|
|
200
|
+
...(fw.conditions ? { conditions: fw.conditions } : {}),
|
|
201
|
+
...(fw.define ? { define: fw.define } : {}),
|
|
202
|
+
createApp: (clientEntry, importQuery) =>
|
|
203
|
+
createWebApp({
|
|
204
|
+
adapter: asAdapter(fw.adapter),
|
|
205
|
+
manifest: discoverRoutes(routesDir, { importQuery }),
|
|
206
|
+
clientEntry,
|
|
207
|
+
...apiOf(backend),
|
|
208
|
+
}),
|
|
209
|
+
})
|
|
210
|
+
console.log(`nifra dev (bun) → http://localhost:${server.port}`)
|
|
211
|
+
return
|
|
212
|
+
}
|
|
129
213
|
// Preflight: `nifra dev` needs `vite` resolvable from the project. Run via `bunx @nifrajs/cli dev` the CLI
|
|
130
214
|
// sits in an isolated install where the project's peer deps don't resolve, so the vite import below fails
|
|
131
215
|
// with an opaque ERR_MODULE_NOT_FOUND. Surface the real fix instead.
|
|
@@ -137,26 +221,33 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
|
|
|
137
221
|
"script (`bun run dev`), not `bunx @nifrajs/cli dev` (which can't resolve the project's peers).",
|
|
138
222
|
)
|
|
139
223
|
}
|
|
140
|
-
const { plugin } = await import("bun")
|
|
141
224
|
const { createViteDevServer } = await import("@nifrajs/web/vite")
|
|
142
225
|
const { framework: fw, routesDir, cwd, backend } = app
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
226
|
+
// No Bun SSR plugins here. `nifra dev` runs the VITE pipeline, and Vite now resolves and compiles
|
|
227
|
+
// route modules for SSR too (via `ssrLoadModule`), so `.vue`/`.svelte`/Solid are handled by the
|
|
228
|
+
// app's `vitePlugins` on both halves.
|
|
229
|
+
//
|
|
230
|
+
// Registering Bun's SFC plugins alongside was the structural intermix: two toolchains compiling the
|
|
231
|
+
// same file in one process, with only one of them governed by Vite's `resolve.dedupe`. That is what
|
|
232
|
+
// made an app's hand-written React alias fail to reach SSR, and it is the condition being removed -
|
|
233
|
+
// not a redundancy worth keeping "just in case".
|
|
147
234
|
const server = await createViteDevServer({
|
|
148
235
|
root: cwd,
|
|
149
236
|
routesDir,
|
|
150
237
|
clientModule: fw.clientModule,
|
|
151
|
-
plugins:
|
|
238
|
+
plugins: app.resolvedPlugins.vitePlugins,
|
|
239
|
+
...(fw.publicDir !== undefined ? { publicDir: fw.publicDir } : {}),
|
|
152
240
|
poll: flags.poll,
|
|
153
241
|
port: flags.port,
|
|
154
242
|
...(fw.conditions ? { conditions: fw.conditions } : {}),
|
|
155
243
|
...(fw.define ? { define: fw.define } : {}),
|
|
156
|
-
|
|
244
|
+
// `load` resolves route modules through VITE, not through Bun. That is what makes the Vite
|
|
245
|
+
// pipeline own the whole phase: the client and the server now agree on every specifier, so
|
|
246
|
+
// `resolve.dedupe` finally governs SSR and the dual-React crash cannot occur.
|
|
247
|
+
createApp: (clientEntry, load) =>
|
|
157
248
|
createWebApp({
|
|
158
249
|
adapter: asAdapter(fw.adapter),
|
|
159
|
-
manifest: discoverRoutes(routesDir, {
|
|
250
|
+
manifest: discoverRoutes(routesDir, { load }),
|
|
160
251
|
clientEntry,
|
|
161
252
|
...apiOf(backend),
|
|
162
253
|
}),
|
|
@@ -171,13 +262,27 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
|
|
|
171
262
|
* `nifra.config.ts`, which pulls in Vite plugins), and the backend from `backend.ts` when present;
|
|
172
263
|
* `buildTarget` generates the per-target server entry from those + the app's `routes/`.
|
|
173
264
|
*/
|
|
174
|
-
async function buildForTarget(app: LoadedApp, target: string,
|
|
175
|
-
const {
|
|
176
|
-
"@nifrajs/web/build"
|
|
177
|
-
)
|
|
265
|
+
async function buildForTarget(app: LoadedApp, target: string, flags: Flags): Promise<void> {
|
|
266
|
+
const { isBuildTarget, renderSizeReport, BUILD_TARGETS } = await import("@nifrajs/web/build")
|
|
178
267
|
if (!isBuildTarget(target)) {
|
|
179
268
|
throw new Error(`[nifra] unknown --target "${target}". Valid: ${BUILD_TARGETS.join(", ")}.`)
|
|
180
269
|
}
|
|
270
|
+
if (flags.vite && flags.bun) {
|
|
271
|
+
throw new Error("[nifra] `nifra build` takes `--vite` or `--bun`, not both.")
|
|
272
|
+
}
|
|
273
|
+
// Which bundler builds this app. Default is Bun; an app whose ONLY transforms are `vitePlugins` gets
|
|
274
|
+
// Vite instead, because the Bun build cannot run them and would silently omit the work. `--vite`/`--bun`
|
|
275
|
+
// force it, and the one forced combination that would drop transforms throws.
|
|
276
|
+
const decision = chooseBuildPipeline(
|
|
277
|
+
app.resolvedPlugins,
|
|
278
|
+
flags.vite ? "vite" : flags.bun ? "bun" : undefined,
|
|
279
|
+
)
|
|
280
|
+
const useVite = decision.pipeline === "vite"
|
|
281
|
+
// Same deploy-dir output — buildTargetVite delegates to the same orchestrator as buildTarget — so only
|
|
282
|
+
// the bundler and the plugin FORMAT (Vite plugins, not Bun) differ.
|
|
283
|
+
const buildTarget = useVite
|
|
284
|
+
? (await import("@nifrajs/web/build-vite")).buildTargetVite
|
|
285
|
+
: (await import("@nifrajs/web/build")).buildTarget
|
|
181
286
|
const { framework: fw, routesDir, outDir, cwd, backend } = app
|
|
182
287
|
// The server entry must import the adapter from `framework.ts` (edge-safe), not the loaded config.
|
|
183
288
|
// `loadApp` guarantees one of them exists; prefer framework.ts so a multi-target app's Vite-plugin
|
|
@@ -188,6 +293,18 @@ async function buildForTarget(app: LoadedApp, target: string, report: boolean):
|
|
|
188
293
|
? resolve(cwd, "nifra.config.ts")
|
|
189
294
|
: resolve(cwd, "framework.ts")
|
|
190
295
|
const backendFile = resolve(cwd, "backend.ts")
|
|
296
|
+
// Plugin FORMAT differs by pipeline: the Bun build takes Bun plugins (clientPlugins/serverPlugins); the
|
|
297
|
+
// Vite build takes the app's Vite plugins (fw.vitePlugins) for BOTH halves. buildTargetWith forwards
|
|
298
|
+
// whatever it's given straight to the chosen bundler, which casts to its own plugin type.
|
|
299
|
+
const plugins = useVite
|
|
300
|
+
? {
|
|
301
|
+
clientPlugins: app.resolvedPlugins.vitePlugins as unknown as BunPlugin[],
|
|
302
|
+
serverPlugins: app.resolvedPlugins.vitePlugins as unknown as BunPlugin[],
|
|
303
|
+
}
|
|
304
|
+
: {
|
|
305
|
+
clientPlugins: asBunPlugins(app.resolvedPlugins.clientPlugins),
|
|
306
|
+
serverPlugins: asBunPlugins(app.resolvedPlugins.serverPlugins),
|
|
307
|
+
}
|
|
191
308
|
const result = await buildTarget(target, {
|
|
192
309
|
routesDir,
|
|
193
310
|
outDir,
|
|
@@ -195,15 +312,19 @@ async function buildForTarget(app: LoadedApp, target: string, report: boolean):
|
|
|
195
312
|
clientModule: fw.clientModule,
|
|
196
313
|
adapterImport: frameworkFile,
|
|
197
314
|
...(backend !== undefined && existsSync(backendFile) ? { backendImport: backendFile } : {}),
|
|
198
|
-
|
|
199
|
-
serverPlugins: asBunPlugins(await resolvePlugins(fw.serverPlugins)),
|
|
315
|
+
...plugins,
|
|
200
316
|
...(fw.conditions ? { conditions: fw.conditions } : {}),
|
|
201
317
|
...(fw.define ? { define: fw.define } : {}),
|
|
318
|
+
...(fw.publicDir !== undefined ? { publicDir: fw.publicDir } : {}),
|
|
319
|
+
...(fw.publicEnvPrefix !== undefined ? { publicEnvPrefix: fw.publicEnvPrefix } : {}),
|
|
202
320
|
// The static target needs a built app to drive prerendering — only build it when targeting static.
|
|
203
321
|
...(target === "static" ? { prerenderApp: await buildPrerenderApp(app) } : {}),
|
|
204
322
|
})
|
|
205
|
-
console.log(`nifra build (${target}) → ${result.run}`)
|
|
206
|
-
|
|
323
|
+
console.log(`nifra build (${target}${useVite ? ", vite" : ""}) → ${result.run}`)
|
|
324
|
+
// An auto-selected pipeline is stated, never silent: the user asked for the default and got something
|
|
325
|
+
// else, and the reason is the thing that makes that legible instead of surprising.
|
|
326
|
+
if (decision.reason !== undefined) console.log(` ${decision.reason}`)
|
|
327
|
+
if (flags.report) console.log(`\n${renderSizeReport(result.size)}`)
|
|
207
328
|
}
|
|
208
329
|
|
|
209
330
|
/** Build the app FACTORY for the `static` target's prerender pass. Registers the
|
|
@@ -217,7 +338,7 @@ async function buildPrerenderApp(
|
|
|
217
338
|
): Promise<(client: BuiltManifest) => { fetch(req: Request): Response | Promise<Response> }> {
|
|
218
339
|
const { plugin } = await import("bun")
|
|
219
340
|
const { framework: fw, routesDir, backend } = app
|
|
220
|
-
for (const p of asBunPlugins(
|
|
341
|
+
for (const p of asBunPlugins(app.resolvedPlugins.serverPlugins)) plugin(p)
|
|
221
342
|
return (client) =>
|
|
222
343
|
createWebApp({
|
|
223
344
|
adapter: asAdapter(fw.adapter),
|
|
@@ -266,6 +387,8 @@ export function parseFlags(args: readonly string[]): Flags {
|
|
|
266
387
|
let poll = Bun.env.CHOKIDAR_USEPOLLING === "1"
|
|
267
388
|
let target = "bun"
|
|
268
389
|
let report = false
|
|
390
|
+
let vite = false
|
|
391
|
+
let bun = false
|
|
269
392
|
for (let i = 0; i < args.length; i++) {
|
|
270
393
|
const a = args[i]
|
|
271
394
|
if ((a === "--port" || a === "-p") && args[i + 1]) port = Number(args[++i])
|
|
@@ -273,11 +396,13 @@ export function parseFlags(args: readonly string[]): Flags {
|
|
|
273
396
|
else if (a === "--poll") poll = true
|
|
274
397
|
else if (a === "--target" && args[i + 1]) target = args[++i] as string
|
|
275
398
|
else if (a === "--report") report = true
|
|
399
|
+
else if (a === "--vite") vite = true
|
|
400
|
+
else if (a === "--bun") bun = true
|
|
276
401
|
}
|
|
277
402
|
if (!Number.isFinite(port) || port < 0 || port > 65535) {
|
|
278
403
|
throw new Error(`[nifra] invalid --port: ${port}`)
|
|
279
404
|
}
|
|
280
|
-
return { port, out, poll, target, report }
|
|
405
|
+
return { port, out, poll, target, report, vite, bun }
|
|
281
406
|
}
|
|
282
407
|
|
|
283
408
|
async function main(): Promise<void> {
|
|
@@ -588,10 +713,34 @@ async function main(): Promise<void> {
|
|
|
588
713
|
const flags = parseFlags(argv.slice(1))
|
|
589
714
|
const app = await loadApp(process.cwd(), flags.out)
|
|
590
715
|
if (command === "dev") await dev(app, flags)
|
|
591
|
-
else if (command === "build") await buildForTarget(app, flags.target, flags
|
|
716
|
+
else if (command === "build") await buildForTarget(app, flags.target, flags)
|
|
592
717
|
else if (command === "context") console.log(describeProject(app))
|
|
593
718
|
else if (command === "routes") {
|
|
594
|
-
|
|
719
|
+
if (argv.includes("--modes")) {
|
|
720
|
+
// `--modes` answers a different question than the default table: not "what URLs exist" but "how
|
|
721
|
+
// does each render, and can the target I'm about to deploy to actually honour that". Resolves
|
|
722
|
+
// against `--target` when given, so a `static` build's server-needing route fails here, in one
|
|
723
|
+
// place, instead of 404ing in production.
|
|
724
|
+
const { buildRouteManifest, renderRouteManifest } = await import(
|
|
725
|
+
"@nifrajs/web/route-manifest"
|
|
726
|
+
)
|
|
727
|
+
const manifest = discoverRoutes(app.routesDir)
|
|
728
|
+
// `flags.target` defaults to "bun" for `build`; here an EXPLICIT `--target` is what opts into
|
|
729
|
+
// gating, so read it from argv rather than inheriting the build default (which would silently gate
|
|
730
|
+
// every `nifra routes --modes` against bun).
|
|
731
|
+
const targetIdx = argv.indexOf("--target")
|
|
732
|
+
const target = targetIdx !== -1 ? argv[targetIdx + 1] : undefined
|
|
733
|
+
const routeManifest = await buildRouteManifest(manifest, {
|
|
734
|
+
...(target !== undefined ? { target } : {}),
|
|
735
|
+
})
|
|
736
|
+
if (argv.includes("--json")) console.log(JSON.stringify(routeManifest, null, 2))
|
|
737
|
+
else console.log(renderRouteManifest(routeManifest))
|
|
738
|
+
// A target that cannot honour a route is a build-breaking fact, not a note: exit non-zero so the
|
|
739
|
+
// same command in CI gates the deploy it is warning about.
|
|
740
|
+
if (routeManifest.conflicts.length > 0) process.exitCode = 1
|
|
741
|
+
} else {
|
|
742
|
+
console.log(await describeRoutes(app, { json: argv.includes("--json") }))
|
|
743
|
+
}
|
|
595
744
|
} else await start(app, flags)
|
|
596
745
|
}
|
|
597
746
|
|
package/src/doctor.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* when a monorepo would hoist it. Relative paths, runtime builtins (node core, `node:`/`bun:`, `bun`),
|
|
11
11
|
* the package's own name, and tsconfig `paths` aliases are excluded — none of them are npm deps.
|
|
12
12
|
*/
|
|
13
|
-
import { realpath } from "node:fs/promises"
|
|
13
|
+
import { realpath, stat } from "node:fs/promises"
|
|
14
14
|
import { builtinModules } from "node:module"
|
|
15
15
|
import { dirname, join, relative } from "node:path"
|
|
16
16
|
import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
|
|
@@ -336,13 +336,75 @@ const displayPath = (cwd: string, path: string): string => {
|
|
|
336
336
|
return rel === "" ? "." : rel
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Resolve the workspace root that actually governs `cwd`, or `undefined` when there is none.
|
|
341
|
+
*
|
|
342
|
+
* Everything below is anchored to a package that declares `workspaces`. In a monorepo you run
|
|
343
|
+
* `nifra check` from the app - `apps/web` - and THAT manifest declares nothing, so the scan collapses
|
|
344
|
+
* to the app itself and the sibling package holding the second copy is never probed. Running from an
|
|
345
|
+
* app subdirectory is the normal case, which made this the configuration the check was blind in.
|
|
346
|
+
*
|
|
347
|
+
* An ancestor is adopted only when its `workspaces` patterns genuinely match `cwd`. A parent
|
|
348
|
+
* directory that happens to contain a manifest is not automatically this project's root, and adopting
|
|
349
|
+
* one would silently widen the scan to an unrelated tree. The walk also stops at a `.git` boundary
|
|
350
|
+
* (checked after the manifest, so a repo root that IS the workspace root still counts) and at the
|
|
351
|
+
* filesystem root.
|
|
352
|
+
*/
|
|
353
|
+
/** Whether `path` exists at all. `stat`, not a file read: a `.git` is a directory in a normal
|
|
354
|
+
* checkout and a FILE in a worktree or submodule, and both mark the same boundary. */
|
|
355
|
+
const pathExists = (path: string): Promise<boolean> =>
|
|
356
|
+
stat(path).then(
|
|
357
|
+
() => true,
|
|
358
|
+
() => false,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
async function findWorkspaceRoot(
|
|
362
|
+
cwd: string,
|
|
363
|
+
): Promise<{ root: string; package: Record<string, unknown> } | undefined> {
|
|
364
|
+
let dir = dirname(cwd)
|
|
365
|
+
for (;;) {
|
|
366
|
+
const manifestPath = join(dir, "package.json")
|
|
367
|
+
const manifest = await readJson(manifestPath)
|
|
368
|
+
if (manifest !== undefined) {
|
|
369
|
+
const patterns = workspacePatterns(manifest)
|
|
370
|
+
if (patterns.length > 0 && (await patternsCover(dir, patterns, cwd))) {
|
|
371
|
+
return { root: dir, package: manifest }
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// A `.git` here means we have reached this project's boundary; anything above belongs to a
|
|
375
|
+
// different repo (or a parent checkout) and must not be adopted.
|
|
376
|
+
if (await pathExists(join(dir, ".git"))) return undefined
|
|
377
|
+
const parent = dirname(dir)
|
|
378
|
+
if (parent === dir) return undefined
|
|
379
|
+
dir = parent
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Whether any of `root`'s workspace patterns resolves to a manifest inside `target`'s package. */
|
|
384
|
+
async function patternsCover(root: string, patterns: string[], target: string): Promise<boolean> {
|
|
385
|
+
for (const pattern of patterns) {
|
|
386
|
+
const packagePattern = `${pattern.replace(/\/$/, "")}/package.json`
|
|
387
|
+
for await (const rel of new Bun.Glob(packagePattern).scan({ cwd: root, dot: false })) {
|
|
388
|
+
if (rel.split(/[\\/]/).includes("node_modules")) continue
|
|
389
|
+
if (dirname(join(root, rel)) === target) return true
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return false
|
|
393
|
+
}
|
|
394
|
+
|
|
339
395
|
/** Find identity-sensitive dependencies that resolve to multiple physical directories. Two copies at
|
|
340
|
-
* the same version still fail: module identity (React hooks, Nifra symbols/registries) is path-based.
|
|
396
|
+
* the same version still fail: module identity (React hooks, Nifra symbols/registries) is path-based.
|
|
397
|
+
*
|
|
398
|
+
* `cwd` is where the user ran the check; the scan is anchored at the workspace root above it (see
|
|
399
|
+
* {@link findWorkspaceRoot}) so sibling packages are probed, while paths are still REPORTED relative
|
|
400
|
+
* to `cwd` so the findings read the way the user's terminal does. */
|
|
341
401
|
export async function collectDuplicateInstalls(
|
|
342
402
|
cwd: string,
|
|
343
403
|
rootPackage: Record<string, unknown>,
|
|
344
404
|
): Promise<DuplicateInstallFinding[]> {
|
|
345
|
-
const
|
|
405
|
+
const workspace = await findWorkspaceRoot(cwd)
|
|
406
|
+
const scanRoot = workspace?.root ?? cwd
|
|
407
|
+
const importers = await workspaceImporters(scanRoot, workspace?.package ?? rootPackage)
|
|
346
408
|
const byPackage = new Map<string, Map<string, { version: string; importers: Set<string> }>>()
|
|
347
409
|
const record = (
|
|
348
410
|
name: string,
|
|
@@ -364,7 +426,10 @@ export async function collectDuplicateInstalls(
|
|
|
364
426
|
for (const name of duplicateTargets(importer.package)) {
|
|
365
427
|
if (importer.package.name === name) continue
|
|
366
428
|
targets.add(name)
|
|
367
|
-
|
|
429
|
+
// Boundary is the SCAN root, not `cwd`: an importer can now sit beside or above the directory
|
|
430
|
+
// the user ran from, and stopping the upward walk at `cwd` would abandon it before reaching the
|
|
431
|
+
// hoisted copy it actually resolves.
|
|
432
|
+
const copy = await resolvedInstalledCopy(importer.root, scanRoot, name)
|
|
368
433
|
if (copy !== undefined) record(name, copy, displayPath(cwd, importer.root))
|
|
369
434
|
}
|
|
370
435
|
}
|
|
@@ -374,8 +439,8 @@ export async function collectDuplicateInstalls(
|
|
|
374
439
|
// out hides the very split it should catch: every declaring package nested onto one copy while the
|
|
375
440
|
// root resolved a different one, which reads as a single copy when only declarers are consulted.
|
|
376
441
|
for (const name of targets) {
|
|
377
|
-
const copy = await resolvedInstalledCopy(
|
|
378
|
-
if (copy !== undefined) record(name, copy,
|
|
442
|
+
const copy = await resolvedInstalledCopy(scanRoot, scanRoot, name)
|
|
443
|
+
if (copy !== undefined) record(name, copy, displayPath(cwd, scanRoot))
|
|
379
444
|
}
|
|
380
445
|
|
|
381
446
|
const findings: DuplicateInstallFinding[] = []
|