@nifrajs/cli 2.7.1 → 2.8.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/mcp-http.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The PUBLIC nifra docs MCP - Streamable-HTTP transport exposing the project-INDEPENDENT tools
3
- * (`nifra_docs`, `nifra_example`, `nifra_types`, `nifra_learn` + the `nifra_examples_app` MCP Apps widget)
3
+ * (`nifra_docs`, `nifra_example`, `nifra_types`, `nifra_learn` + the `nifra_gallery` MCP Apps widget)
4
4
  * so any remote AI agent can learn nifra without a local checkout. They read the bundled corpus
5
5
  * (llms-full.txt / examples.json / types.json shipped in this package) and ignore `cwd`, so they reuse the
6
6
  * exact definitions from {@link projectTools} - one source, no drift.
@@ -23,14 +23,14 @@ import { loadTypesCorpus } from "./types-search.js";
23
23
  export { examplesAppTool, examplesWidget } from "./mcp-app-tool.js";
24
24
  export { docsTools } from "./mcp-docs-tools.js";
25
25
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
26
- const VERSION = "2.7.1";
26
+ const VERSION = "2.8.0";
27
27
  const SERVER_INFO = { name: "nifra-docs", version: VERSION };
28
28
  // Derive the GET/health tool list from the tools actually served, so the line can never drift from them.
29
29
  const docsHealth = (tools) => `nifra docs MCP - POST JSON-RPC 2.0 here (methods: initialize, tools/list, tools/call). Tools: ${tools
30
30
  .map((tool) => tool.name)
31
31
  .join(", ")}.`;
32
32
  /** The project-independent tools, reading the package's bundled corpus from disk (CLI use): the text
33
- * docs tools plus the `nifra_examples_app` MCP Apps widget tool. */
33
+ * docs tools plus the `nifra_gallery` MCP Apps widget tool. */
34
34
  export function publicDocsTools() {
35
35
  return [
36
36
  ...docsTools(loadDocsCorpus, loadExamplesCorpus, loadTypesCorpus),
@@ -46,7 +46,7 @@ export function respondMcpHttp(request, tools, options = {}) {
46
46
  return respondMcpHttpCore(request, tools, SERVER_INFO, { health: docsHealth(tools), ...options });
47
47
  }
48
48
  /** The CLI HTTP handler: serves the disk-backed corpus tools + registers the examples widget's `ui://`
49
- * resource so MCP Apps hosts can render `nifra_examples_app`. (`nifra docs-mcp` / `bun run` this file.) */
49
+ * resource so MCP Apps hosts can render `nifra_gallery`. (`nifra docs-mcp` / `bun run` this file.) */
50
50
  export function handleMcpHttp(request) {
51
51
  return respondMcpHttp(request, publicDocsTools(), {
52
52
  features: {
@@ -1 +1 @@
1
- {"version":3,"file":"mcp-http.js","sourceRoot":"","sources":["../src/mcp-http.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAuB,cAAc,IAAI,kBAAkB,EAAE,MAAM,mBAAmB,CAAA;AAC7F,OAAO,EAAgB,OAAO,EAAE,MAAM,uBAAuB,CAAA;AAC7D,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AACjD,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;AAClD,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AACnE,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAC/C,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AAInD,qGAAqG;AACrG,0FAA0F;AAC1F,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AACnE,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAG/C,+FAA+F;AAC/F,MAAM,OAAO,GAAG,OAAO,CAAA;AACvB,MAAM,WAAW,GAAG,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,EAAE,OAAO,EAAE,CAAA;AAC5D,yGAAyG;AACzG,MAAM,UAAU,GAAG,CAAC,KAAgB,EAAU,EAAE,CAC9C,iGAAiG,KAAK;KACnG,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC;KACxB,IAAI,CAAC,IAAI,CAAC,GAAG,CAAA;AAElB;oEACoE;AACpE,MAAM,UAAU,eAAe;IAC7B,OAAO;QACL,GAAG,SAAS,CAAC,cAAc,EAAE,kBAAkB,EAAE,eAAe,CAAC;QACjE,eAAe,CAAC,kBAAkB,CAAC;KACpC,CAAA;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAC5B,OAAgB,EAChB,KAAgB,EAChB,UAA0B,EAAE;IAE5B,OAAO,kBAAkB,CAAC,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,EAAE,MAAM,EAAE,UAAU,CAAC,KAAK,CAAC,EAAE,GAAG,OAAO,EAAE,CAAC,CAAA;AACnG,CAAC;AAED;2GAC2G;AAC3G,MAAM,UAAU,aAAa,CAAC,OAAgB;IAC5C,OAAO,cAAc,CAAC,OAAO,EAAE,eAAe,EAAE,EAAE;QAChD,QAAQ,EAAE;YACR,SAAS,EAAE,CAAC,cAAc,CAAC,QAAQ,CAAC;YACpC,EAAE,EAAE,EAAE,SAAS,EAAE,CAAC,OAAO,CAAC,EAAE;YAC5B,YAAY,EACV,wMAAwM;SAC3M;KACF,CAAC,CAAA;AACJ,CAAC;AAED;;;;;GAKG;AACH,eAAe;IACb,IAAI,EAAE,OAAO,GAAG,KAAK,WAAW,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI;IACtE,KAAK,EAAE,aAAa;CACrB,CAAA"}
1
+ {"version":3,"file":"mcp-http.js","sourceRoot":"","sources":["../src/mcp-http.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAuB,cAAc,IAAI,kBAAkB,EAAE,MAAM,mBAAmB,CAAA;AAC7F,OAAO,EAAgB,OAAO,EAAE,MAAM,uBAAuB,CAAA;AAC7D,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AACjD,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;AAClD,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AACnE,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAC/C,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AAInD,qGAAqG;AACrG,0FAA0F;AAC1F,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AACnE,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAG/C,+FAA+F;AAC/F,MAAM,OAAO,GAAG,OAAO,CAAA;AACvB,MAAM,WAAW,GAAG,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,EAAE,OAAO,EAAE,CAAA;AAC5D,yGAAyG;AACzG,MAAM,UAAU,GAAG,CAAC,KAAgB,EAAU,EAAE,CAC9C,iGAAiG,KAAK;KACnG,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC;KACxB,IAAI,CAAC,IAAI,CAAC,GAAG,CAAA;AAElB;+DAC+D;AAC/D,MAAM,UAAU,eAAe;IAC7B,OAAO;QACL,GAAG,SAAS,CAAC,cAAc,EAAE,kBAAkB,EAAE,eAAe,CAAC;QACjE,eAAe,CAAC,kBAAkB,CAAC;KACpC,CAAA;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAC5B,OAAgB,EAChB,KAAgB,EAChB,UAA0B,EAAE;IAE5B,OAAO,kBAAkB,CAAC,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,EAAE,MAAM,EAAE,UAAU,CAAC,KAAK,CAAC,EAAE,GAAG,OAAO,EAAE,CAAC,CAAA;AACnG,CAAC;AAED;sGACsG;AACtG,MAAM,UAAU,aAAa,CAAC,OAAgB;IAC5C,OAAO,cAAc,CAAC,OAAO,EAAE,eAAe,EAAE,EAAE;QAChD,QAAQ,EAAE;YACR,SAAS,EAAE,CAAC,cAAc,CAAC,QAAQ,CAAC;YACpC,EAAE,EAAE,EAAE,SAAS,EAAE,CAAC,OAAO,CAAC,EAAE;YAC5B,YAAY,EACV,wMAAwM;SAC3M;KACF,CAAC,CAAA;AACJ,CAAC;AAED;;;;;GAKG;AACH,eAAe;IACb,IAAI,EAAE,OAAO,GAAG,KAAK,WAAW,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI;IACtE,KAAK,EAAE,aAAa;CACrB,CAAA"}
@@ -842,10 +842,11 @@ one stub per export, so the bodies - and every module they import, including you
842
842
  stay on the server. Both pipelines do this identically, and the Bun and Vite transforms are
843
843
  held to byte-identical output by a parity test.
844
844
 
845
- One pipeline cannot: `nifra dev --bun`. Bun's dev-server bundler accepts no plugins,
846
- so a `*.fn` module there would ship whole, secrets included. That command refuses to
847
- start on an app containing one rather than shipping it. `nifra build` and
848
- `nifra dev` (Vite) are unaffected.
845
+ `nifra dev --bun` applies it too. Bun's dev-server bundler accepts plugins only
846
+ through bunfig's `[serve.static]` channel, so that command generates a config
847
+ under `.nifra/dev-bun/` carrying the same stub plugin and relaunches itself with
848
+ `--config=` pointing at it - verified per launch, refusing to serve if the
849
+ boundary cannot be proven active. Identical stubs across all three pipelines.
849
850
 
850
851
  ### It is a route, so assurance applies
851
852
 
@@ -3861,6 +3862,27 @@ One gap: **CSS Modules**. Bun's dev-server bundler has no
3861
3862
  refuses `--bun` for a CSS-Modules app rather than serving a broken client. Plain CSS
3862
3863
  and Tailwind work normally.
3863
3864
 
3865
+ **
3866
+ Server functions and `*.server` modules work under `--bun`
3867
+ **
3868
+ - and the plumbing is worth knowing. The client build strips them: a `*.fn` module
3869
+ is replaced with an RPC stub and a `*.server` module is emptied, so their bodies
3870
+ (DB handles, secrets, imports) never reach a browser. Bun's dev-server bundling accepts
3871
+ plugins only through `bunfig.toml` (`[serve.static] plugins`) - not
3872
+ programmatically, and a runtime `Bun.plugin` never reaches it (upstream ask:
3873
+ oven-sh/bun#36830). So `nifra dev --bun` generates a config under
3874
+ `.nifra/dev-bun/` carrying the _same_ production boundary plugins, merges
3875
+ your own bunfig's `[serve.static] plugins` and `preload` entries, and
3876
+ re-launches itself once with `--config=` pointing at it. Same stubs as
3877
+ `nifra build`, byte for byte - one implementation, three pipelines.
3878
+
3879
+ Two pipelines, one contract: what keeps them honest is not hope but guards. Both loops serve
3880
+ `public/` through the same handler production uses, the build dedupes the UI
3881
+ framework to one physical copy on both paths, SSR verifies at render time that the renderer
3882
+ and your components share one core (naming both directories if not), and
3883
+ `nifra doctor` flags a stale workspace `dist` before it can shadow
3884
+ source. A divergence between the loops is treated as a bug, not a caveat.
3885
+
3864
3886
  ```ts
3865
3887
  // doc-check: skip - fragment: routesDir/outDir/clientModule/createApp are your app's dev config.
3866
3888
  // dev.ts - Bun-native HMR, no Vite in the process
package/docs/types.json CHANGED
@@ -958,6 +958,13 @@
958
958
  "package": "@nifrajs/web",
959
959
  "signature": "export interface BuildDiagnosticOptions {\n readonly request?: {\n readonly method: string;\n readonly url: string;\n };\n /** Project root; frames outside it are treated as non-user. Defaults to `process.cwd()`. */\n readonly root?: string;\n /** Injectable source reader (tests pass a fake; production reads the filesystem). */\n readonly read?: SourceReader;\n}"
960
960
  },
961
+ {
962
+ "name": "buildFailureDetail",
963
+ "kind": "function",
964
+ "package": "@nifrajs/web",
965
+ "signature": "export declare function buildFailureDetail(err: unknown): string;",
966
+ "doc": "/**\n * Format a client-build rejection for the guard's report. Bun.build rejects with an AggregateError\n * whose message is just \"Bundle failed\" - the actionable part (which file, which import) lives in\n * `.errors`. Surface it, or the guard reports a failure while hiding the reason.\n */"
967
+ },
961
968
  {
962
969
  "name": "buildManifest",
963
970
  "kind": "function",
@@ -4157,7 +4164,7 @@
4157
4164
  "kind": "function",
4158
4165
  "package": "@nifrajs/cli",
4159
4166
  "signature": "export declare function handleMcpHttp(request: Request): Promise<Response>;",
4160
- "doc": "/** The CLI HTTP handler: serves the disk-backed corpus tools + registers the examples widget's `ui://`\n * resource so MCP Apps hosts can render `nifra_examples_app`. (`nifra docs-mcp` / `bun run` this file.) */"
4167
+ "doc": "/** The CLI HTTP handler: serves the disk-backed corpus tools + registers the examples widget's `ui://`\n * resource so MCP Apps hosts can render `nifra_gallery`. (`nifra docs-mcp` / `bun run` this file.) */"
4161
4168
  },
4162
4169
  {
4163
4170
  "name": "Handler",
@@ -7071,7 +7078,7 @@
7071
7078
  "kind": "function",
7072
7079
  "package": "@nifrajs/cli",
7073
7080
  "signature": "export declare function publicDocsTools(): McpTool[];",
7074
- "doc": "/** The project-independent tools, reading the package's bundled corpus from disk (CLI use): the text\n * docs tools plus the `nifra_examples_app` MCP Apps widget tool. */"
7081
+ "doc": "/** The project-independent tools, reading the package's bundled corpus from disk (CLI use): the text\n * docs tools plus the `nifra_gallery` MCP Apps widget tool. */"
7075
7082
  },
7076
7083
  {
7077
7084
  "name": "publicEnvDefines",
@@ -10814,14 +10821,14 @@
10814
10821
  "name": "VERSION",
10815
10822
  "kind": "const",
10816
10823
  "package": "@nifrajs/core",
10817
- "signature": "export declare const VERSION: \"2.7.1\";",
10824
+ "signature": "export declare const VERSION: \"2.8.0\";",
10818
10825
  "doc": "/**\n * Current package version. A hardcoded literal on purpose - core runs on the edge (no fs), so it can't\n * read its own package.json at runtime. `scripts/version.ts` rewrites it on every release bump and\n * `check:publish` asserts it equals `@nifrajs/core`'s package version.\n */"
10819
10826
  },
10820
10827
  {
10821
10828
  "name": "VERSION",
10822
10829
  "kind": "const",
10823
10830
  "package": "nifra",
10824
- "signature": "export declare const VERSION: \"2.7.1\";",
10831
+ "signature": "export declare const VERSION: \"2.8.0\";",
10825
10832
  "doc": "/**\n * Current package version. A hardcoded literal on purpose - core runs on the edge (no fs), so it can't\n * read its own package.json at runtime. `scripts/version.ts` rewrites it on every release bump and\n * `check:publish` asserts it equals `@nifrajs/core`'s package version.\n */"
10826
10833
  },
10827
10834
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/cli",
3
- "version": "2.7.1",
3
+ "version": "2.8.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.7.1",
45
- "@nifrajs/core": "2.7.1",
46
- "@nifrajs/mcp": "2.7.1",
47
- "@nifrajs/runner": "2.7.1",
48
- "@nifrajs/schema": "2.7.1",
49
- "@nifrajs/testing": "2.7.1",
50
- "@nifrajs/web": "2.7.1",
51
- "create-nifra": "2.7.1"
44
+ "@nifrajs/client": "2.8.0",
45
+ "@nifrajs/core": "2.8.0",
46
+ "@nifrajs/mcp": "2.8.0",
47
+ "@nifrajs/runner": "2.8.0",
48
+ "@nifrajs/schema": "2.8.0",
49
+ "@nifrajs/testing": "2.8.0",
50
+ "@nifrajs/web": "2.8.0",
51
+ "create-nifra": "2.8.0"
52
52
  },
53
53
  "repository": {
54
54
  "type": "git",
package/src/check.ts CHANGED
@@ -511,9 +511,145 @@ interface SqlExpressionShape {
511
511
  readonly parameterizedTag: boolean
512
512
  }
513
513
 
514
+ /** Module-scope `const` initializers by name - the one binding form the SQL scanner resolves. */
515
+ type ModuleConsts = ReadonlyMap<string, TSApi.Expression>
516
+
517
+ /** Collect the file's top-level `const NAME = <initializer>` declarations. Destructured names, `let`,
518
+ * `var`, and anything nested stay out on purpose: a module-scope `const` is the only form that
519
+ * provably cannot carry request data. */
520
+ function moduleConstsOf(ts: TypeScriptApi, source: TSApi.SourceFile): ModuleConsts {
521
+ const map = new Map<string, TSApi.Expression>()
522
+ for (const statement of source.statements) {
523
+ if (!ts.isVariableStatement(statement)) continue
524
+ if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue
525
+ for (const declaration of statement.declarationList.declarations) {
526
+ if (ts.isIdentifier(declaration.name) && declaration.initializer !== undefined) {
527
+ map.set(declaration.name.text, declaration.initializer)
528
+ }
529
+ }
530
+ }
531
+ return map
532
+ }
533
+
534
+ /** Whether `name`, read at `at`, might be re-declared by anything between the read and module scope.
535
+ * Conservative on purpose: the OUTERMOST enclosing function/block subtree is scanned for ANY binding
536
+ * of the name (parameter, `var`/`let`/`const`, catch, local function, destructuring element) - so a
537
+ * hoisted `var` in a sibling block, or a binding in a branch that never runs, still refuses
538
+ * resolution. Over-refusal flags a safe line (the pre-feature behavior); under-refusal would resolve
539
+ * the wrong binding, which is why the cheap-and-broad scan wins over scope simulation. A use with no
540
+ * enclosing function or block reads module scope directly and cannot be shadowed. */
541
+ function isShadowedAt(ts: TypeScriptApi, at: TSApi.Node, name: string): boolean {
542
+ let scope: TSApi.Node | undefined
543
+ for (let node: TSApi.Node | undefined = at.parent; node !== undefined; node = node.parent) {
544
+ if (ts.isSourceFile(node)) break
545
+ if (ts.isFunctionLike(node) || ts.isBlock(node)) scope = node
546
+ }
547
+ if (scope === undefined) return false
548
+ const bindsName = (binding: TSApi.BindingName): boolean =>
549
+ ts.isIdentifier(binding)
550
+ ? binding.text === name
551
+ : binding.elements.some(
552
+ (element) =>
553
+ !ts.isOmittedExpression(element) &&
554
+ (ts.isIdentifier(element.name) ? element.name.text === name : bindsName(element.name)),
555
+ )
556
+ let found = false
557
+ const scan = (node: TSApi.Node): void => {
558
+ if (found) return
559
+ if (
560
+ (ts.isVariableDeclaration(node) || ts.isParameter(node)) &&
561
+ bindsName(node.name as TSApi.BindingName)
562
+ ) {
563
+ found = true
564
+ return
565
+ }
566
+ if (ts.isFunctionDeclaration(node) && node.name?.text === name) {
567
+ found = true
568
+ return
569
+ }
570
+ ts.forEachChild(node, scan)
571
+ }
572
+ scan(scope)
573
+ return found
574
+ }
575
+
576
+ const CONST_RESOLUTION_DEPTH = 5
577
+
578
+ /**
579
+ * Resolve an interpolated expression to compile-time text, or `undefined` when it can carry runtime
580
+ * data. Pure syntax, no type checker - the same honest limit the rule already documents. What
581
+ * resolves: string/number literals, no-substitution templates, templates whose every span resolves,
582
+ * ternaries with both branches resolvable (BOTH branch texts are returned, so a hostile keyword in
583
+ * either still feeds the keyword scan), `as`/`satisfies` wrappers, and a same-file module-scope
584
+ * `const` whose initializer resolves (recursively, depth-capped). Everything else - an imported
585
+ * name, `let`, a parameter, a call, a member access, a shadowed name - stays unresolved and the
586
+ * interpolation is flagged exactly as before.
587
+ */
588
+ function resolveStaticSqlText(
589
+ ts: TypeScriptApi,
590
+ node: TSApi.Expression,
591
+ consts: ModuleConsts,
592
+ depth: number,
593
+ ): string | undefined {
594
+ if (depth > CONST_RESOLUTION_DEPTH) return undefined
595
+ if (ts.isParenthesizedExpression(node)) {
596
+ return resolveStaticSqlText(ts, node.expression, consts, depth)
597
+ }
598
+ if (ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) {
599
+ return resolveStaticSqlText(ts, node.expression, consts, depth)
600
+ }
601
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text
602
+ if (ts.isNumericLiteral(node)) return node.text
603
+ if (ts.isTemplateExpression(node)) {
604
+ let text = node.head.text
605
+ for (const span of node.templateSpans) {
606
+ const part = resolveStaticSqlText(ts, span.expression, consts, depth + 1)
607
+ if (part === undefined) return undefined
608
+ text += part + span.literal.text
609
+ }
610
+ return text
611
+ }
612
+ if (ts.isConditionalExpression(node)) {
613
+ const whenTrue = resolveStaticSqlText(ts, node.whenTrue, consts, depth + 1)
614
+ const whenFalse = resolveStaticSqlText(ts, node.whenFalse, consts, depth + 1)
615
+ if (whenTrue === undefined || whenFalse === undefined) return undefined
616
+ return `${whenTrue} ${whenFalse}`
617
+ }
618
+ if (ts.isIdentifier(node)) {
619
+ const initializer = consts.get(node.text)
620
+ if (initializer === undefined) return undefined
621
+ if (isShadowedAt(ts, node, node.text)) return undefined
622
+ return resolveStaticSqlText(ts, initializer, consts, depth + 1)
623
+ }
624
+ return undefined
625
+ }
626
+
627
+ /** A template's contribution to the shape: resolved span texts fold into `staticText` (so the
628
+ * keyword scan still sees what a `const` carries); any unresolvable span keeps it `dynamic`. */
629
+ function templateShape(
630
+ ts: TypeScriptApi,
631
+ template: TSApi.TemplateExpression,
632
+ consts: ModuleConsts | undefined,
633
+ ): { staticText: string; dynamic: boolean } {
634
+ let staticText = template.head.text
635
+ let dynamic = false
636
+ for (const span of template.templateSpans) {
637
+ const resolved =
638
+ consts === undefined ? undefined : resolveStaticSqlText(ts, span.expression, consts, 0)
639
+ if (resolved === undefined) dynamic = true
640
+ else staticText += resolved
641
+ staticText += span.literal.text
642
+ }
643
+ return { staticText, dynamic }
644
+ }
645
+
514
646
  /** Describe only the first argument's syntax; comments/strings elsewhere cannot influence the result. */
515
- function sqlExpressionShape(ts: TypeScriptApi, node: TSApi.Expression): SqlExpressionShape {
516
- if (ts.isParenthesizedExpression(node)) return sqlExpressionShape(ts, node.expression)
647
+ function sqlExpressionShape(
648
+ ts: TypeScriptApi,
649
+ node: TSApi.Expression,
650
+ consts?: ModuleConsts,
651
+ ): SqlExpressionShape {
652
+ if (ts.isParenthesizedExpression(node)) return sqlExpressionShape(ts, node.expression, consts)
517
653
  if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
518
654
  return {
519
655
  staticText: node.text,
@@ -529,9 +665,10 @@ function sqlExpressionShape(ts: TypeScriptApi, node: TSApi.Expression): SqlExpre
529
665
  (ts.isIdentifier(tag) && tag.text === "sql") ||
530
666
  (ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql")
531
667
  const template = node.template
532
- const staticText = ts.isTemplateExpression(template)
533
- ? [template.head.text, ...template.templateSpans.map((span) => span.literal.text)].join("")
534
- : template.text
668
+ const templated = ts.isTemplateExpression(template)
669
+ ? templateShape(ts, template, parameterizedTag ? undefined : consts)
670
+ : { staticText: template.text, dynamic: false }
671
+ const staticText = templated.staticText
535
672
  // Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
536
673
  // that reads syntax and runs no type checker. `sql` is what postgres.js, drizzle, slonik and Bun's
537
674
  // own driver all call theirs, and `Prisma.sql` is Prisma's; taking those at their word is a
@@ -547,32 +684,53 @@ function sqlExpressionShape(ts: TypeScriptApi, node: TSApi.Expression): SqlExpre
547
684
  // is treated as plain interpolation.
548
685
  return {
549
686
  staticText,
550
- dynamic: ts.isTemplateExpression(template) && !parameterizedTag,
687
+ dynamic: templated.dynamic && !parameterizedTag,
551
688
  concatenated: false,
552
689
  literal: !ts.isTemplateExpression(template),
553
690
  parameterizedTag,
554
691
  }
555
692
  }
556
693
  if (ts.isTemplateExpression(node)) {
694
+ const templated = templateShape(ts, node, consts)
557
695
  return {
558
- staticText: [node.head.text, ...node.templateSpans.map((span) => span.literal.text)].join(""),
559
- dynamic: true,
696
+ staticText: templated.staticText,
697
+ dynamic: templated.dynamic,
560
698
  concatenated: false,
561
699
  literal: false,
562
700
  parameterizedTag: false,
563
701
  }
564
702
  }
565
703
  if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
566
- const left = sqlExpressionShape(ts, node.left)
567
- const right = sqlExpressionShape(ts, node.right)
704
+ const left = sqlExpressionShape(ts, node.left, consts)
705
+ const right = sqlExpressionShape(ts, node.right, consts)
568
706
  return {
569
707
  staticText: left.staticText + right.staticText,
570
- dynamic: left.dynamic || right.dynamic || !left.literal || !right.literal,
708
+ // A resolved const operand is static text, not a dynamic part - so the old `!literal` test
709
+ // narrows to what it always actually meant: an operand that carries runtime data (dynamic) or
710
+ // a binding tag being concatenated into plain text (which un-parameterizes it).
711
+ dynamic: left.dynamic || right.dynamic || left.parameterizedTag || right.parameterizedTag,
571
712
  concatenated: true,
572
713
  literal: left.literal && right.literal,
573
714
  parameterizedTag: false,
574
715
  }
575
716
  }
717
+ // A resolvable expression (a module-`const` name, a literal ternary) contributes its compile-time
718
+ // text and stays non-dynamic - this is what lets `"SELECT " + COLS + " FROM t"` and
719
+ // `db.query(`${COLS} FROM t WHERE id = $1`)` pass while an imported or mutable name still flags.
720
+ // `literal` stays false: the named escape hatches (`unsafe`, `$queryRawUnsafe`) keep flagging a
721
+ // resolved const, because taking statement text from ANY variable is what their rule warns about.
722
+ if (consts !== undefined) {
723
+ const resolved = resolveStaticSqlText(ts, node, consts, 0)
724
+ if (resolved !== undefined) {
725
+ return {
726
+ staticText: resolved,
727
+ dynamic: false,
728
+ concatenated: false,
729
+ literal: false,
730
+ parameterizedTag: false,
731
+ }
732
+ }
733
+ }
576
734
  return {
577
735
  staticText: "",
578
736
  dynamic: true,
@@ -623,12 +781,16 @@ export function scanInterpolatedSql(
623
781
  source as TSApi.SourceFile & { readonly parseDiagnostics?: readonly TSApi.Diagnostic[] }
624
782
  ).parseDiagnostics
625
783
  if (parseDiagnostics !== undefined && parseDiagnostics.length > 0) return out
784
+ // Module-scope consts, resolved once per file: an interpolated same-file `const` cannot carry
785
+ // request data, so the shared-projection idiom (`const COLS = "id, name"` … `${COLS}`) stays quiet
786
+ // while imported, mutable, parameter, and shadowed names flag exactly as before.
787
+ const consts = moduleConstsOf(ts, source)
626
788
  const visit = (node: TSApi.Node): void => {
627
789
  if (ts.isCallExpression(node)) {
628
790
  const sink = sqlMethod(ts, node)
629
791
  const argument = node.arguments[0]
630
792
  if (sink !== undefined && argument !== undefined) {
631
- const shape = sqlExpressionShape(ts, argument)
793
+ const shape = sqlExpressionShape(ts, argument, consts)
632
794
  const unsafeEscape =
633
795
  ALWAYS_UNSAFE.has(sink.method) && !shape.literal && !shape.parameterizedTag
634
796
  const assembledSql =
package/src/cli.ts CHANGED
@@ -12,13 +12,7 @@
12
12
  import { existsSync } from "node:fs"
13
13
  import { resolve } from "node:path"
14
14
  import { inProcessClient } from "@nifrajs/client"
15
- import {
16
- createWebApp,
17
- DEFAULT_DEV_PORT,
18
- type RenderAdapter,
19
- SERVER_FN_MODULE,
20
- SERVER_ONLY_MODULE,
21
- } from "@nifrajs/web"
15
+ import { createWebApp, DEFAULT_DEV_PORT, type RenderAdapter } from "@nifrajs/web"
22
16
  import { discoverRoutes } from "@nifrajs/web/fs"
23
17
  import type { BunPlugin } from "bun"
24
18
  import { describeProject, describeRoutes } from "./introspect.ts"
@@ -154,7 +148,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
154
148
  \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
155
149
 
156
150
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
157
- const CLI_VERSION = "2.7.1"
151
+ const CLI_VERSION = "2.8.0"
158
152
 
159
153
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
160
154
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
@@ -210,6 +204,15 @@ export function formatCliError(err: unknown): string {
210
204
  * an app without CSS Modules still gets the Bun dev loop.
211
205
  */
212
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.
213
216
  const { Glob } = await import("bun")
214
217
  const offenders: string[] = []
215
218
  for await (const file of new Glob("**/*.module.css").scan({ cwd: app.cwd, dot: false })) {
@@ -217,58 +220,6 @@ export async function assertBunDevSupportsApp(app: LoadedApp): Promise<void> {
217
220
  offenders.push(file)
218
221
  if (offenders.length >= 5) break
219
222
  }
220
- // Server functions are the same trade with a worse consequence. The client build replaces a `*.fn`
221
- // module with stubs so its body never reaches a browser; Bun's dev-server bundler takes no plugins, so
222
- // there it would be bundled WHOLE - the function bodies and every secret they import. Measured, not
223
- // assumed: a runtime `Bun.plugin` onLoad does not reach `HTMLBundle`, so the module ships raw.
224
- //
225
- // `*.server` modules are the same trade again: the client build EMPTIES them, and with no plugins
226
- // here they ship whole.
227
- //
228
- // Both are matched with the SAME regexes the transforms use, imported rather than re-encoded. A
229
- // hand-written glob here is how these two gates drifted from the pipelines they guard: the old
230
- // `**/*.fn.{ts,tsx,js,jsx}` missed `.fn.mts`, `.fn.cts`, `.fn.mjs` and `.fn.cjs`, every one of which
231
- // both build pipelines stub - so a module the framework calls a server function was waved straight
232
- // into the browser by the check that exists to stop exactly that. A gate written in a second dialect
233
- // is a gate that eventually disagrees with the thing it guards.
234
- const leaking: string[] = []
235
- const serverOnly: string[] = []
236
- for await (const file of new Glob("**/*.{server,fn,ts,tsx,js,jsx,mts,cts,mjs,cjs}").scan({
237
- cwd: app.cwd,
238
- dot: false,
239
- })) {
240
- if (/(^|\/)(node_modules|dist|\.nifra|\.git)\//.test(file)) continue
241
- // Strip the source extension once so the conventions' regexes see `src/todos.fn` / `src/db.server`,
242
- // which is the shape they match. The glob also includes the extensionless `.fn` / `.server` forms;
243
- // those already have the matcher-ready shape and pass through unchanged.
244
- const stem = file.replace(/\.[cm]?[jt]sx?$/, "")
245
- if (SERVER_FN_MODULE.test(stem)) {
246
- if (leaking.length < 5) leaking.push(file)
247
- } else if (SERVER_ONLY_MODULE.test(stem)) {
248
- if (serverOnly.length < 5) serverOnly.push(file)
249
- }
250
- if (leaking.length >= 5 && serverOnly.length >= 5) break
251
- }
252
- if (serverOnly.length > 0) {
253
- throw new Error(
254
- "[nifra] `nifra dev --bun` can't empty `*.server` modules: Bun's DEV-server bundler takes no " +
255
- "plugins, so a server-only module would be bundled WHOLE into the browser - along with whatever " +
256
- "it imports and any secret it holds. `nifra build` and `nifra dev` (Vite) both replace it with " +
257
- "an empty module, so only this pipeline is affected.\n" +
258
- ` Server-only modules found: ${serverOnly.join(", ")}\n` +
259
- " Use `nifra dev` (the Vite pipeline), or reach these from a route/loader for the Bun dev loop.",
260
- )
261
- }
262
- if (leaking.length > 0) {
263
- throw new Error(
264
- "[nifra] `nifra dev --bun` can't transform server functions: Bun's DEV-server bundler takes no " +
265
- "plugins, so a `*.fn` module would be bundled WHOLE into the browser - its function bodies and " +
266
- "anything they import, secrets included. `nifra build` and `nifra dev` (Vite) both replace it " +
267
- "with client stubs, so only this pipeline is affected.\n" +
268
- ` Server-function modules found: ${leaking.join(", ")}\n` +
269
- " Use `nifra dev` (the Vite pipeline), or move these calls behind a route for the Bun dev loop.",
270
- )
271
- }
272
223
  if (offenders.length === 0) return
273
224
  throw new Error(
274
225
  "[nifra] `nifra dev --bun` can't compile CSS Modules: Bun's DEV-server bundler has no `*.module.css` " +
@@ -285,6 +236,42 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
285
236
  // each pipeline is selectable in BOTH phases, and neither ever runs inside the other.
286
237
  if (flags.bun) {
287
238
  await assertBunDevSupportsApp(app)
239
+ // Bun's dev-server bundler takes plugins only via bunfig `[serve.static]`, read at process
240
+ // start - so the boundary plugins (server-fn stubs, server-only emptying) are delivered by
241
+ // generating a config and re-execing this same command once with `--config=`. The child proves
242
+ // it IS the configured child with a per-launch random token (matched against the file the
243
+ // parent just wrote, consumed on first read). A fixed sentinel here would be a secret-leak
244
+ // switch: any inherited environment could set it, skip the generation, and serve the app with
245
+ // no boundary plugins at all. An unprovable token acts as a parent - fail closed.
246
+ const { consumeLaunchToken, writeBunDevConfig } = await import("./dev-bun-config.ts")
247
+ if (!consumeLaunchToken(app.cwd, process.env.NIFRA_BUN_DEV_TOKEN)) {
248
+ // Re-exec depth backstop: token verification makes recursion terminate (the direct child
249
+ // always verifies), so hitting this means filesystem/env sabotage - refuse over fork-looping.
250
+ const depth = Number(process.env.NIFRA_BUN_DEV_DEPTH ?? "0")
251
+ if (depth >= 3) {
252
+ throw new Error(
253
+ "[nifra] `nifra dev --bun` could not verify its generated dev config after several " +
254
+ "relaunches. Something is rewriting `.nifra/dev-bun/` or the environment between " +
255
+ "launches; refusing to serve without the client-boundary plugins.",
256
+ )
257
+ }
258
+ const { bunfigPath, launchToken } = await writeBunDevConfig(app.cwd)
259
+ const child = Bun.spawn(
260
+ [process.execPath, `--config=${bunfigPath}`, ...process.argv.slice(1)],
261
+ {
262
+ stdio: ["inherit", "inherit", "inherit"],
263
+ env: {
264
+ ...process.env,
265
+ NIFRA_BUN_DEV_TOKEN: launchToken,
266
+ NIFRA_BUN_DEV_DEPTH: String(depth + 1),
267
+ },
268
+ },
269
+ )
270
+ const forward = (): void => child.kill("SIGINT")
271
+ process.on("SIGINT", forward)
272
+ process.on("SIGTERM", forward)
273
+ process.exit(await child.exited)
274
+ }
288
275
  const { createDevServer } = await import("@nifrajs/web/dev")
289
276
  const { framework: fw, routesDir, outDir, backend } = app
290
277
  const server = await createDevServer({