@nifrajs/cli 2.7.0 → 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/check.d.ts.map +1 -1
- package/dist/check.js +161 -12
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +42 -55
- package/dist/cli.js.map +1 -1
- package/dist/dev-bun-config.d.ts +36 -0
- package/dist/dev-bun-config.d.ts.map +1 -0
- package/dist/dev-bun-config.js +179 -0
- package/dist/dev-bun-config.js.map +1 -0
- package/dist/mcp-app-tool.d.ts +1 -1
- package/dist/mcp-app-tool.d.ts.map +1 -1
- package/dist/mcp-app-tool.js +8 -4
- package/dist/mcp-app-tool.js.map +1 -1
- package/dist/mcp-docs-tools.js +1 -1
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-http.d.ts +3 -3
- package/dist/mcp-http.d.ts.map +1 -1
- package/dist/mcp-http.js +4 -4
- package/dist/mcp-http.js.map +1 -1
- package/docs/llms-full.txt +54 -27
- package/docs/types.json +13 -6
- package/package.json +9 -9
- package/src/check.ts +174 -12
- package/src/cli.ts +47 -60
- package/src/dev-bun-config.ts +204 -0
- package/src/mcp-app-tool.ts +8 -4
- package/src/mcp-docs-tools.ts +1 -1
- package/src/mcp-http.ts +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/cli",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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.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(
|
|
516
|
-
|
|
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
|
|
533
|
-
?
|
|
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:
|
|
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:
|
|
559
|
-
dynamic:
|
|
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
|
-
|
|
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.
|
|
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({
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `nifra dev --bun` boundary-plugin channel.
|
|
3
|
+
*
|
|
4
|
+
* Bun's dev-server bundling (`Bun.serve` HTML imports) accepts plugins ONLY through bunfig's
|
|
5
|
+
* `[serve.static] plugins` - there is no programmatic option (upstream ask: oven-sh/bun#36830), and
|
|
6
|
+
* a runtime `Bun.plugin()` never reaches it. Without a plugin there, a `*.fn` server-function module
|
|
7
|
+
* would ship to the browser WHOLE (body, DB handles, secrets) instead of as the RPC stub the
|
|
8
|
+
* production client build emits, and a `*.server` module would ship instead of being emptied.
|
|
9
|
+
*
|
|
10
|
+
* So the CLI generates, under `.nifra/dev-bun/`:
|
|
11
|
+
* - `boundary-plugin.ts` - default-exports one plugin composing the SAME `serverFnStubPlugin` +
|
|
12
|
+
* `serverOnlyEmptyPlugin` the production build uses (imported from `@nifrajs/web/build`, resolved
|
|
13
|
+
* from the app - identical stubs, not a re-implementation);
|
|
14
|
+
* - `bunfig.toml` - the app's ENTIRE bunfig carried over verbatim (jsx, loaders, defines, install
|
|
15
|
+
* settings - dropping any of them would give dev a different Bun than the app configured), with
|
|
16
|
+
* `[serve.static] plugins` merged to put the boundary plugin first and path-bearing entries
|
|
17
|
+
* (`preload`, plugin paths) re-rooted at the app, because bunfig resolves relative entries
|
|
18
|
+
* against the CONFIG file's directory;
|
|
19
|
+
* - `launch-token` - a per-launch random value (see below).
|
|
20
|
+
*
|
|
21
|
+
* `dev --bun` then re-execs itself once with `--config=<generated>` (bunfig is read at process
|
|
22
|
+
* start, so the already-running CLI can't adopt it) - see `cli.ts`.
|
|
23
|
+
*
|
|
24
|
+
* ## Why a random launch token, not a fixed marker
|
|
25
|
+
*
|
|
26
|
+
* The child must know it IS the configured child. A fixed sentinel (`SOME_VAR=1`) would let any
|
|
27
|
+
* inherited environment - a CI wrapper, a shell profile, another tool - satisfy the check and skip
|
|
28
|
+
* the config generation entirely, serving the app with NO boundary plugins: a silent secret leak
|
|
29
|
+
* switched on by an environment variable. So the parent mints a fresh `crypto.randomUUID()` per
|
|
30
|
+
* launch, writes it beside the generated config, and passes it in the child's env; the child accepts
|
|
31
|
+
* only a token that matches the file, and CONSUMES the file on first read so a stale env value can
|
|
32
|
+
* never satisfy a later launch. An unverifiable token means the process acts as a parent and
|
|
33
|
+
* regenerates - fail closed, never serve unproven.
|
|
34
|
+
*/
|
|
35
|
+
import { timingSafeEqual } from "node:crypto"
|
|
36
|
+
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
|
37
|
+
import { resolve } from "node:path"
|
|
38
|
+
|
|
39
|
+
/** TOML string literal: basic (double-quoted) form with the two escapes it needs. */
|
|
40
|
+
function tomlString(value: string): string {
|
|
41
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A parsed bunfig: plain data from `Bun.TOML.parse`. */
|
|
45
|
+
type BunfigData = Record<string, unknown>
|
|
46
|
+
|
|
47
|
+
/** Parse the app's bunfig. Malformed TOML fails LOUDLY with the parse error - silently treating a
|
|
48
|
+
* broken config as empty would run dev with different Bun settings than the app declares. */
|
|
49
|
+
export function parseUserBunfig(toml: string | undefined): BunfigData {
|
|
50
|
+
if (toml === undefined) return {}
|
|
51
|
+
try {
|
|
52
|
+
return (Bun.TOML.parse(toml) ?? {}) as BunfigData
|
|
53
|
+
} catch (err) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`[nifra] the app's bunfig.toml does not parse, so \`nifra dev --bun\` cannot carry it into ` +
|
|
56
|
+
`the dev server's config: ${err instanceof Error ? err.message : String(err)}`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const BARE_KEY = /^[A-Za-z0-9_-]+$/
|
|
62
|
+
const tomlKey = (key: string): string => (BARE_KEY.test(key) ? key : tomlString(key))
|
|
63
|
+
|
|
64
|
+
function tomlValue(value: unknown, path: string): string {
|
|
65
|
+
if (typeof value === "string") return tomlString(value)
|
|
66
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
|
67
|
+
if (Array.isArray(value)) {
|
|
68
|
+
return `[${value.map((item, i) => tomlValue(item, `${path}[${i}]`)).join(", ")}]`
|
|
69
|
+
}
|
|
70
|
+
throw new Error(
|
|
71
|
+
`[nifra] bunfig field \`${path}\` has a shape \`nifra dev --bun\` cannot re-serialize ` +
|
|
72
|
+
`(supported: strings, numbers, booleans, arrays of those, and nested tables). ` +
|
|
73
|
+
`Move it out of bunfig.toml for the Bun dev loop, or report the shape.`,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const isTable = (value: unknown): value is BunfigData =>
|
|
78
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
79
|
+
|
|
80
|
+
/** Serialize a parsed bunfig back to TOML: scalar/array keys first, then nested tables as dotted
|
|
81
|
+
* `[section]` headers, recursively. Every field round-trips or the serializer throws - a field
|
|
82
|
+
* silently dropped here would be a dev server running different Bun settings than the app's. */
|
|
83
|
+
export function serializeBunfig(data: BunfigData): string {
|
|
84
|
+
const lines: string[] = []
|
|
85
|
+
const walk = (table: BunfigData, prefix: string): void => {
|
|
86
|
+
const scalars = Object.entries(table).filter(([, v]) => !isTable(v))
|
|
87
|
+
const tables = Object.entries(table).filter((entry): entry is [string, BunfigData] =>
|
|
88
|
+
isTable(entry[1]),
|
|
89
|
+
)
|
|
90
|
+
if (prefix !== "" && scalars.length > 0) lines.push(`[${prefix}]`)
|
|
91
|
+
for (const [key, value] of scalars) {
|
|
92
|
+
lines.push(`${tomlKey(key)} = ${tomlValue(value, prefix === "" ? key : `${prefix}.${key}`)}`)
|
|
93
|
+
}
|
|
94
|
+
for (const [key, value] of tables) {
|
|
95
|
+
const next = prefix === "" ? tomlKey(key) : `${prefix}.${tomlKey(key)}`
|
|
96
|
+
// A table with ONLY sub-tables still needs no header of its own; recursion emits the leaves.
|
|
97
|
+
walk(value, next)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
walk(data, "")
|
|
101
|
+
return `${lines.join("\n")}\n`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Render the generated bunfig: the user's config carried whole, with the boundary plugin prepended
|
|
105
|
+
* to `[serve.static] plugins` and path-bearing entries (`preload`, plugin paths) re-rooted at the
|
|
106
|
+
* APP - bunfig resolves relative entries against the config file's own directory, which is
|
|
107
|
+
* `.nifra/dev-bun/`. */
|
|
108
|
+
export function renderDevBunfig(
|
|
109
|
+
boundaryPluginPath: string,
|
|
110
|
+
user: BunfigData,
|
|
111
|
+
appRoot: string,
|
|
112
|
+
): string {
|
|
113
|
+
const resolveEntry = (entry: unknown): unknown =>
|
|
114
|
+
typeof entry === "string" && entry.startsWith(".") ? resolve(appRoot, entry) : entry
|
|
115
|
+
const data: BunfigData = structuredClone(user)
|
|
116
|
+
if (data.preload !== undefined) {
|
|
117
|
+
const entries = Array.isArray(data.preload) ? data.preload : [data.preload]
|
|
118
|
+
data.preload = entries.map(resolveEntry)
|
|
119
|
+
}
|
|
120
|
+
const serve = isTable(data.serve) ? data.serve : {}
|
|
121
|
+
data.serve = serve
|
|
122
|
+
const serveStatic = isTable(serve.static) ? serve.static : {}
|
|
123
|
+
serve.static = serveStatic
|
|
124
|
+
const existing = Array.isArray(serveStatic.plugins) ? serveStatic.plugins : []
|
|
125
|
+
serveStatic.plugins = [boundaryPluginPath, ...existing.map(resolveEntry)]
|
|
126
|
+
return (
|
|
127
|
+
"# Generated by `nifra dev --bun` - do not edit. Regenerated on every dev start.\n" +
|
|
128
|
+
"# The app's own bunfig is carried over verbatim; [serve.static] plugins additionally deliver\n" +
|
|
129
|
+
"# the client-boundary plugins (server-fn stubs, server-only emptying) to Bun's dev-server\n" +
|
|
130
|
+
"# bundler, which only accepts plugins via this channel (oven-sh/bun#36830).\n" +
|
|
131
|
+
serializeBunfig(data)
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The generated plugin module: compose the two production boundary plugins into one default
|
|
136
|
+
* export. Import specifiers are bare so they resolve from the APP's install (the module lives
|
|
137
|
+
* under the app's `.nifra/`), keeping dev stubs byte-identical to production's. */
|
|
138
|
+
export function renderBoundaryPluginModule(): string {
|
|
139
|
+
return `// Generated by \`nifra dev --bun\` - do not edit. Regenerated on every dev start.
|
|
140
|
+
import { serverFnStubPlugin, serverOnlyEmptyPlugin } from "@nifrajs/web/build"
|
|
141
|
+
|
|
142
|
+
const fn = serverFnStubPlugin()
|
|
143
|
+
const serverOnly = serverOnlyEmptyPlugin()
|
|
144
|
+
|
|
145
|
+
export default {
|
|
146
|
+
name: "nifra-dev-boundary",
|
|
147
|
+
setup(build) {
|
|
148
|
+
serverOnly.setup(build)
|
|
149
|
+
fn.setup(build)
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface BunDevConfig {
|
|
156
|
+
readonly bunfigPath: string
|
|
157
|
+
/** Per-launch random value the re-exec'd child must present (and the file copy it must match). */
|
|
158
|
+
readonly launchToken: string
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const TOKEN_FILE = "launch-token"
|
|
162
|
+
|
|
163
|
+
/** Write the generated config for an app; returns the bunfig path to re-exec with and the fresh
|
|
164
|
+
* launch token to pass in the child's environment. */
|
|
165
|
+
export async function writeBunDevConfig(appRoot: string): Promise<BunDevConfig> {
|
|
166
|
+
const dir = resolve(appRoot, ".nifra", "dev-bun")
|
|
167
|
+
mkdirSync(dir, { recursive: true })
|
|
168
|
+
const pluginPath = resolve(dir, "boundary-plugin.ts")
|
|
169
|
+
writeFileSync(pluginPath, renderBoundaryPluginModule())
|
|
170
|
+
const userToml = await Bun.file(resolve(appRoot, "bunfig.toml"))
|
|
171
|
+
.text()
|
|
172
|
+
.catch(() => undefined)
|
|
173
|
+
const bunfigPath = resolve(dir, "bunfig.toml")
|
|
174
|
+
writeFileSync(bunfigPath, renderDevBunfig(pluginPath, parseUserBunfig(userToml), appRoot))
|
|
175
|
+
const launchToken = crypto.randomUUID()
|
|
176
|
+
writeFileSync(resolve(dir, TOKEN_FILE), launchToken)
|
|
177
|
+
return { bunfigPath, launchToken }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Child-side proof of a configured launch: the presented token must match the token file the parent
|
|
182
|
+
* just wrote, compared in constant time. The file is CONSUMED (deleted) on the first attempt either
|
|
183
|
+
* way, so a stale environment value can never satisfy a later launch. `false` means this process
|
|
184
|
+
* cannot prove the boundary config is active and must act as a parent (regenerate + re-exec) - the
|
|
185
|
+
* fail-closed direction.
|
|
186
|
+
*/
|
|
187
|
+
export function consumeLaunchToken(appRoot: string, presented: string | undefined): boolean {
|
|
188
|
+
if (presented === undefined || presented === "") return false
|
|
189
|
+
const path = resolve(appRoot, ".nifra", "dev-bun", TOKEN_FILE)
|
|
190
|
+
let stored: string
|
|
191
|
+
try {
|
|
192
|
+
stored = readFileSync(path, "utf8")
|
|
193
|
+
} catch {
|
|
194
|
+
return false
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
unlinkSync(path)
|
|
198
|
+
} catch {
|
|
199
|
+
// Already consumed by a race - the comparison below still decides on the bytes read.
|
|
200
|
+
}
|
|
201
|
+
const a = Buffer.from(stored)
|
|
202
|
+
const b = Buffer.from(presented)
|
|
203
|
+
return a.length === b.length && timingSafeEqual(a, b)
|
|
204
|
+
}
|
package/src/mcp-app-tool.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The `
|
|
2
|
+
* The `nifra_gallery` MCP tool: nifra's verified code examples as an MCP Apps widget. It returns the
|
|
3
3
|
* examples as `structuredContent`, rendered by the {@link examplesWidget} `ui://` widget in MCP Apps hosts
|
|
4
4
|
* (ChatGPT Apps, MCPJam, Goose); text-only hosts still get the example names as plain text. Part of the
|
|
5
5
|
* public docs-MCP tool set (served by `handleMcpHttp`), so every self-host gets it from one definition.
|
|
@@ -50,10 +50,14 @@ export const examplesWidget = defineMcpWidget({
|
|
|
50
50
|
/** The widget-backed tool. `loadExamples` is injected (disk on the CLI, cached fetch on the edge). */
|
|
51
51
|
export function examplesAppTool(loadExamples: () => Promise<Example[] | undefined>): McpTool {
|
|
52
52
|
return defineMcpTool({
|
|
53
|
-
name: "
|
|
54
|
-
annotations: {
|
|
53
|
+
name: "nifra_gallery",
|
|
54
|
+
annotations: {
|
|
55
|
+
title: "Browse the nifra example gallery",
|
|
56
|
+
readOnlyHint: true,
|
|
57
|
+
openWorldHint: false,
|
|
58
|
+
},
|
|
55
59
|
description:
|
|
56
|
-
"
|
|
60
|
+
"Open an interactive, filterable gallery of ALL of nifra's verified code examples (MCP Apps widget) - for browsing and discovering what exists. NOT for fetching one snippet as text: use nifra_example for that. Pass query to pre-filter; the widget also filters client-side.",
|
|
57
61
|
inputSchema: {
|
|
58
62
|
type: "object",
|
|
59
63
|
properties: { query: { type: "string", description: "Pre-filter examples by keyword." } },
|
package/src/mcp-docs-tools.ts
CHANGED
|
@@ -58,7 +58,7 @@ export function docsTools(
|
|
|
58
58
|
openWorldHint: false,
|
|
59
59
|
},
|
|
60
60
|
description:
|
|
61
|
-
'Get a VERIFIED, copy-pasteable nifra code example for a task - auth route, file upload, ISR page, loader/action, typed client, SSE, deployment, etc. Every snippet is typechecked against the installed nifra version, so it compiles as-is. PREFER THIS over writing nifra code from memory (training data drifts). Call with no query for the grouped index; pass query (e.g. "protected route", "upload", "isr revalidate") for matching snippets.',
|
|
61
|
+
'Get a VERIFIED, copy-pasteable nifra code example for a task - auth route, file upload, ISR page, loader/action, typed client, SSE, deployment, etc. Every snippet is typechecked against the installed nifra version, so it compiles as-is. PREFER THIS over writing nifra code from memory (training data drifts). Returns code as TEXT for direct use; to visually browse the whole example set instead, use nifra_gallery. Call with no query for the grouped index; pass query (e.g. "protected route", "upload", "isr revalidate") for matching snippets.',
|
|
62
62
|
inputSchema: {
|
|
63
63
|
type: "object",
|
|
64
64
|
properties: {
|