@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.
- package/README.md +2 -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 +129 -66
- 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 +26 -6
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +92 -2
- 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-http.js.map +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/dist/sdk.d.ts +10 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +229 -0
- package/dist/sdk.js.map +1 -0
- package/docs/llms-full.txt +246 -17
- package/docs/types.json +725 -16
- package/package.json +9 -9
- package/src/check.ts +54 -5
- package/src/cli.ts +131 -67
- package/src/dev-bun-config.ts +57 -14
- package/src/doctor.ts +28 -1
- package/src/introspect.ts +124 -4
- 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/sdk.ts +260 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/cli",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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.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
|
|
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
|
*
|
|
@@ -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`:
|
|
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
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
[
|
|
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
|
-
//
|
|
283
|
-
//
|
|
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:
|
|
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}${
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
|
|
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("--
|
|
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
|
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)
|