@nifrajs/cli 2.7.1 → 2.8.1
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 +3 -1
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +178 -19
- 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/doctor.d.ts.map +1 -1
- package/dist/doctor.js +4 -1
- package/dist/doctor.js.map +1 -1
- 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 +126 -6
- package/docs/types.json +11 -4
- package/package.json +9 -9
- package/src/check.ts +192 -19
- package/src/cli.ts +47 -60
- package/src/dev-bun-config.ts +204 -0
- package/src/doctor.ts +19 -6
- package/src/mcp-app-tool.ts +8 -4
- package/src/mcp-docs-tools.ts +1 -1
- package/src/mcp-http.ts +4 -4
package/src/check.ts
CHANGED
|
@@ -45,10 +45,15 @@ const FETCH_CALL = /(?<![.\w])fetch\s*\(/g
|
|
|
45
45
|
const HTTP_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
|
|
46
46
|
const SIMPLE_REWRITE_METHODS = new Set(["GET", "DELETE", "HEAD", "OPTIONS"])
|
|
47
47
|
|
|
48
|
-
// Don't scan deps, build output, generated client entries
|
|
49
|
-
//
|
|
50
|
-
const
|
|
51
|
-
/(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)
|
|
48
|
+
// Don't scan deps, build output, or generated client entries. `dist(-<runtime>)?` also covers
|
|
49
|
+
// per-runtime output dirs (dist-bun/dist-node/dist-deno/dist-vercel). Never source, for any scan.
|
|
50
|
+
const IGNORED_DIR =
|
|
51
|
+
/(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\//
|
|
52
|
+
// A test/spec module. Excluded from `nifra check`'s scans, which are about what SHIPS - a test
|
|
53
|
+
// legitimately drives `fetch`, hand-rolls a client, and calls a route directly. It is NOT excluded from
|
|
54
|
+
// `nifra doctor`: tsc typechecks tests, so an import a test declares nowhere is a real broken build.
|
|
55
|
+
const TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/
|
|
56
|
+
const IGNORED = new RegExp(`${IGNORED_DIR.source}|${TEST_FILE.source}`)
|
|
52
57
|
|
|
53
58
|
// A file under `routes/` - a page module bundled for the browser, where a server-only import is unsafe.
|
|
54
59
|
const ROUTE_FILE = /(^|\/)routes\//
|
|
@@ -511,9 +516,145 @@ interface SqlExpressionShape {
|
|
|
511
516
|
readonly parameterizedTag: boolean
|
|
512
517
|
}
|
|
513
518
|
|
|
519
|
+
/** Module-scope `const` initializers by name - the one binding form the SQL scanner resolves. */
|
|
520
|
+
type ModuleConsts = ReadonlyMap<string, TSApi.Expression>
|
|
521
|
+
|
|
522
|
+
/** Collect the file's top-level `const NAME = <initializer>` declarations. Destructured names, `let`,
|
|
523
|
+
* `var`, and anything nested stay out on purpose: a module-scope `const` is the only form that
|
|
524
|
+
* provably cannot carry request data. */
|
|
525
|
+
function moduleConstsOf(ts: TypeScriptApi, source: TSApi.SourceFile): ModuleConsts {
|
|
526
|
+
const map = new Map<string, TSApi.Expression>()
|
|
527
|
+
for (const statement of source.statements) {
|
|
528
|
+
if (!ts.isVariableStatement(statement)) continue
|
|
529
|
+
if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue
|
|
530
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
531
|
+
if (ts.isIdentifier(declaration.name) && declaration.initializer !== undefined) {
|
|
532
|
+
map.set(declaration.name.text, declaration.initializer)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return map
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Whether `name`, read at `at`, might be re-declared by anything between the read and module scope.
|
|
540
|
+
* Conservative on purpose: the OUTERMOST enclosing function/block subtree is scanned for ANY binding
|
|
541
|
+
* of the name (parameter, `var`/`let`/`const`, catch, local function, destructuring element) - so a
|
|
542
|
+
* hoisted `var` in a sibling block, or a binding in a branch that never runs, still refuses
|
|
543
|
+
* resolution. Over-refusal flags a safe line (the pre-feature behavior); under-refusal would resolve
|
|
544
|
+
* the wrong binding, which is why the cheap-and-broad scan wins over scope simulation. A use with no
|
|
545
|
+
* enclosing function or block reads module scope directly and cannot be shadowed. */
|
|
546
|
+
function isShadowedAt(ts: TypeScriptApi, at: TSApi.Node, name: string): boolean {
|
|
547
|
+
let scope: TSApi.Node | undefined
|
|
548
|
+
for (let node: TSApi.Node | undefined = at.parent; node !== undefined; node = node.parent) {
|
|
549
|
+
if (ts.isSourceFile(node)) break
|
|
550
|
+
if (ts.isFunctionLike(node) || ts.isBlock(node)) scope = node
|
|
551
|
+
}
|
|
552
|
+
if (scope === undefined) return false
|
|
553
|
+
const bindsName = (binding: TSApi.BindingName): boolean =>
|
|
554
|
+
ts.isIdentifier(binding)
|
|
555
|
+
? binding.text === name
|
|
556
|
+
: binding.elements.some(
|
|
557
|
+
(element) =>
|
|
558
|
+
!ts.isOmittedExpression(element) &&
|
|
559
|
+
(ts.isIdentifier(element.name) ? element.name.text === name : bindsName(element.name)),
|
|
560
|
+
)
|
|
561
|
+
let found = false
|
|
562
|
+
const scan = (node: TSApi.Node): void => {
|
|
563
|
+
if (found) return
|
|
564
|
+
if (
|
|
565
|
+
(ts.isVariableDeclaration(node) || ts.isParameter(node)) &&
|
|
566
|
+
bindsName(node.name as TSApi.BindingName)
|
|
567
|
+
) {
|
|
568
|
+
found = true
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
if (ts.isFunctionDeclaration(node) && node.name?.text === name) {
|
|
572
|
+
found = true
|
|
573
|
+
return
|
|
574
|
+
}
|
|
575
|
+
ts.forEachChild(node, scan)
|
|
576
|
+
}
|
|
577
|
+
scan(scope)
|
|
578
|
+
return found
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const CONST_RESOLUTION_DEPTH = 5
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Resolve an interpolated expression to compile-time text, or `undefined` when it can carry runtime
|
|
585
|
+
* data. Pure syntax, no type checker - the same honest limit the rule already documents. What
|
|
586
|
+
* resolves: string/number literals, no-substitution templates, templates whose every span resolves,
|
|
587
|
+
* ternaries with both branches resolvable (BOTH branch texts are returned, so a hostile keyword in
|
|
588
|
+
* either still feeds the keyword scan), `as`/`satisfies` wrappers, and a same-file module-scope
|
|
589
|
+
* `const` whose initializer resolves (recursively, depth-capped). Everything else - an imported
|
|
590
|
+
* name, `let`, a parameter, a call, a member access, a shadowed name - stays unresolved and the
|
|
591
|
+
* interpolation is flagged exactly as before.
|
|
592
|
+
*/
|
|
593
|
+
function resolveStaticSqlText(
|
|
594
|
+
ts: TypeScriptApi,
|
|
595
|
+
node: TSApi.Expression,
|
|
596
|
+
consts: ModuleConsts,
|
|
597
|
+
depth: number,
|
|
598
|
+
): string | undefined {
|
|
599
|
+
if (depth > CONST_RESOLUTION_DEPTH) return undefined
|
|
600
|
+
if (ts.isParenthesizedExpression(node)) {
|
|
601
|
+
return resolveStaticSqlText(ts, node.expression, consts, depth)
|
|
602
|
+
}
|
|
603
|
+
if (ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) {
|
|
604
|
+
return resolveStaticSqlText(ts, node.expression, consts, depth)
|
|
605
|
+
}
|
|
606
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text
|
|
607
|
+
if (ts.isNumericLiteral(node)) return node.text
|
|
608
|
+
if (ts.isTemplateExpression(node)) {
|
|
609
|
+
let text = node.head.text
|
|
610
|
+
for (const span of node.templateSpans) {
|
|
611
|
+
const part = resolveStaticSqlText(ts, span.expression, consts, depth + 1)
|
|
612
|
+
if (part === undefined) return undefined
|
|
613
|
+
text += part + span.literal.text
|
|
614
|
+
}
|
|
615
|
+
return text
|
|
616
|
+
}
|
|
617
|
+
if (ts.isConditionalExpression(node)) {
|
|
618
|
+
const whenTrue = resolveStaticSqlText(ts, node.whenTrue, consts, depth + 1)
|
|
619
|
+
const whenFalse = resolveStaticSqlText(ts, node.whenFalse, consts, depth + 1)
|
|
620
|
+
if (whenTrue === undefined || whenFalse === undefined) return undefined
|
|
621
|
+
return `${whenTrue} ${whenFalse}`
|
|
622
|
+
}
|
|
623
|
+
if (ts.isIdentifier(node)) {
|
|
624
|
+
const initializer = consts.get(node.text)
|
|
625
|
+
if (initializer === undefined) return undefined
|
|
626
|
+
if (isShadowedAt(ts, node, node.text)) return undefined
|
|
627
|
+
return resolveStaticSqlText(ts, initializer, consts, depth + 1)
|
|
628
|
+
}
|
|
629
|
+
return undefined
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** A template's contribution to the shape: resolved span texts fold into `staticText` (so the
|
|
633
|
+
* keyword scan still sees what a `const` carries); any unresolvable span keeps it `dynamic`. */
|
|
634
|
+
function templateShape(
|
|
635
|
+
ts: TypeScriptApi,
|
|
636
|
+
template: TSApi.TemplateExpression,
|
|
637
|
+
consts: ModuleConsts | undefined,
|
|
638
|
+
): { staticText: string; dynamic: boolean } {
|
|
639
|
+
let staticText = template.head.text
|
|
640
|
+
let dynamic = false
|
|
641
|
+
for (const span of template.templateSpans) {
|
|
642
|
+
const resolved =
|
|
643
|
+
consts === undefined ? undefined : resolveStaticSqlText(ts, span.expression, consts, 0)
|
|
644
|
+
if (resolved === undefined) dynamic = true
|
|
645
|
+
else staticText += resolved
|
|
646
|
+
staticText += span.literal.text
|
|
647
|
+
}
|
|
648
|
+
return { staticText, dynamic }
|
|
649
|
+
}
|
|
650
|
+
|
|
514
651
|
/** Describe only the first argument's syntax; comments/strings elsewhere cannot influence the result. */
|
|
515
|
-
function sqlExpressionShape(
|
|
516
|
-
|
|
652
|
+
function sqlExpressionShape(
|
|
653
|
+
ts: TypeScriptApi,
|
|
654
|
+
node: TSApi.Expression,
|
|
655
|
+
consts?: ModuleConsts,
|
|
656
|
+
): SqlExpressionShape {
|
|
657
|
+
if (ts.isParenthesizedExpression(node)) return sqlExpressionShape(ts, node.expression, consts)
|
|
517
658
|
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
518
659
|
return {
|
|
519
660
|
staticText: node.text,
|
|
@@ -529,9 +670,10 @@ function sqlExpressionShape(ts: TypeScriptApi, node: TSApi.Expression): SqlExpre
|
|
|
529
670
|
(ts.isIdentifier(tag) && tag.text === "sql") ||
|
|
530
671
|
(ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql")
|
|
531
672
|
const template = node.template
|
|
532
|
-
const
|
|
533
|
-
?
|
|
534
|
-
: template.text
|
|
673
|
+
const templated = ts.isTemplateExpression(template)
|
|
674
|
+
? templateShape(ts, template, parameterizedTag ? undefined : consts)
|
|
675
|
+
: { staticText: template.text, dynamic: false }
|
|
676
|
+
const staticText = templated.staticText
|
|
535
677
|
// Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
|
|
536
678
|
// that reads syntax and runs no type checker. `sql` is what postgres.js, drizzle, slonik and Bun's
|
|
537
679
|
// own driver all call theirs, and `Prisma.sql` is Prisma's; taking those at their word is a
|
|
@@ -547,32 +689,53 @@ function sqlExpressionShape(ts: TypeScriptApi, node: TSApi.Expression): SqlExpre
|
|
|
547
689
|
// is treated as plain interpolation.
|
|
548
690
|
return {
|
|
549
691
|
staticText,
|
|
550
|
-
dynamic:
|
|
692
|
+
dynamic: templated.dynamic && !parameterizedTag,
|
|
551
693
|
concatenated: false,
|
|
552
694
|
literal: !ts.isTemplateExpression(template),
|
|
553
695
|
parameterizedTag,
|
|
554
696
|
}
|
|
555
697
|
}
|
|
556
698
|
if (ts.isTemplateExpression(node)) {
|
|
699
|
+
const templated = templateShape(ts, node, consts)
|
|
557
700
|
return {
|
|
558
|
-
staticText:
|
|
559
|
-
dynamic:
|
|
701
|
+
staticText: templated.staticText,
|
|
702
|
+
dynamic: templated.dynamic,
|
|
560
703
|
concatenated: false,
|
|
561
704
|
literal: false,
|
|
562
705
|
parameterizedTag: false,
|
|
563
706
|
}
|
|
564
707
|
}
|
|
565
708
|
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
566
|
-
const left = sqlExpressionShape(ts, node.left)
|
|
567
|
-
const right = sqlExpressionShape(ts, node.right)
|
|
709
|
+
const left = sqlExpressionShape(ts, node.left, consts)
|
|
710
|
+
const right = sqlExpressionShape(ts, node.right, consts)
|
|
568
711
|
return {
|
|
569
712
|
staticText: left.staticText + right.staticText,
|
|
570
|
-
|
|
713
|
+
// A resolved const operand is static text, not a dynamic part - so the old `!literal` test
|
|
714
|
+
// narrows to what it always actually meant: an operand that carries runtime data (dynamic) or
|
|
715
|
+
// a binding tag being concatenated into plain text (which un-parameterizes it).
|
|
716
|
+
dynamic: left.dynamic || right.dynamic || left.parameterizedTag || right.parameterizedTag,
|
|
571
717
|
concatenated: true,
|
|
572
718
|
literal: left.literal && right.literal,
|
|
573
719
|
parameterizedTag: false,
|
|
574
720
|
}
|
|
575
721
|
}
|
|
722
|
+
// A resolvable expression (a module-`const` name, a literal ternary) contributes its compile-time
|
|
723
|
+
// text and stays non-dynamic - this is what lets `"SELECT " + COLS + " FROM t"` and
|
|
724
|
+
// `db.query(`${COLS} FROM t WHERE id = $1`)` pass while an imported or mutable name still flags.
|
|
725
|
+
// `literal` stays false: the named escape hatches (`unsafe`, `$queryRawUnsafe`) keep flagging a
|
|
726
|
+
// resolved const, because taking statement text from ANY variable is what their rule warns about.
|
|
727
|
+
if (consts !== undefined) {
|
|
728
|
+
const resolved = resolveStaticSqlText(ts, node, consts, 0)
|
|
729
|
+
if (resolved !== undefined) {
|
|
730
|
+
return {
|
|
731
|
+
staticText: resolved,
|
|
732
|
+
dynamic: false,
|
|
733
|
+
concatenated: false,
|
|
734
|
+
literal: false,
|
|
735
|
+
parameterizedTag: false,
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
576
739
|
return {
|
|
577
740
|
staticText: "",
|
|
578
741
|
dynamic: true,
|
|
@@ -623,12 +786,16 @@ export function scanInterpolatedSql(
|
|
|
623
786
|
source as TSApi.SourceFile & { readonly parseDiagnostics?: readonly TSApi.Diagnostic[] }
|
|
624
787
|
).parseDiagnostics
|
|
625
788
|
if (parseDiagnostics !== undefined && parseDiagnostics.length > 0) return out
|
|
789
|
+
// Module-scope consts, resolved once per file: an interpolated same-file `const` cannot carry
|
|
790
|
+
// request data, so the shared-projection idiom (`const COLS = "id, name"` … `${COLS}`) stays quiet
|
|
791
|
+
// while imported, mutable, parameter, and shadowed names flag exactly as before.
|
|
792
|
+
const consts = moduleConstsOf(ts, source)
|
|
626
793
|
const visit = (node: TSApi.Node): void => {
|
|
627
794
|
if (ts.isCallExpression(node)) {
|
|
628
795
|
const sink = sqlMethod(ts, node)
|
|
629
796
|
const argument = node.arguments[0]
|
|
630
797
|
if (sink !== undefined && argument !== undefined) {
|
|
631
|
-
const shape = sqlExpressionShape(ts, argument)
|
|
798
|
+
const shape = sqlExpressionShape(ts, argument, consts)
|
|
632
799
|
const unsafeEscape =
|
|
633
800
|
ALWAYS_UNSAFE.has(sink.method) && !shape.literal && !shape.parameterizedTag
|
|
634
801
|
const assembledSql =
|
|
@@ -1028,8 +1195,12 @@ export function scanResponseRoutes(file: string, content: string): SourceFinding
|
|
|
1028
1195
|
/** The opt-out pragma that suppresses the {@link scanResponseRoutes} advisory for an intentional raw Response. */
|
|
1029
1196
|
const RAW_RESPONSE_PRAGMA = "nifra-expect raw-response"
|
|
1030
1197
|
|
|
1031
|
-
/** Walk the project's `.ts`/`.tsx` source (skipping deps
|
|
1032
|
-
* Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`.
|
|
1198
|
+
/** Walk the project's `.ts`/`.tsx` source (skipping deps and build output), calling `visit` per file.
|
|
1199
|
+
* Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`.
|
|
1200
|
+
*
|
|
1201
|
+
* `includeTests` widens the surface to `*.test.ts`/`*.spec.ts`, which the default excludes. It exists
|
|
1202
|
+
* for `nifra doctor`: `tsc` compiles tests, so a test importing a package no manifest declares is a
|
|
1203
|
+
* build that a clean install cannot produce - the exact false confidence doctor is for. */
|
|
1033
1204
|
/**
|
|
1034
1205
|
* Paths git considers ignored under `cwd`, in ONE batched `git check-ignore` call - so `.gitignore`
|
|
1035
1206
|
* (root + nested + the global excludesfile) is honoured, not just the built-in {@link IGNORED} list. This
|
|
@@ -1066,12 +1237,14 @@ async function gitIgnored(cwd: string, rels: readonly string[]): Promise<Set<str
|
|
|
1066
1237
|
export async function walkSource(
|
|
1067
1238
|
cwd: string,
|
|
1068
1239
|
visit: (rel: string, content: string) => void,
|
|
1240
|
+
opts: { readonly includeTests?: boolean } = {},
|
|
1069
1241
|
): Promise<void> {
|
|
1242
|
+
const skip = opts.includeTests === true ? IGNORED_DIR : IGNORED
|
|
1070
1243
|
// List candidates first (cheap - no reads), drop the built-in ignores, then exclude gitignored paths in
|
|
1071
1244
|
// one batch before the (expensive) reads. So a gitignored generated/build tree is never read or scanned.
|
|
1072
1245
|
const rels: string[] = []
|
|
1073
1246
|
for await (const rel of new Glob("**/*.{ts,tsx,mts,cts}").scan({ cwd, dot: false })) {
|
|
1074
|
-
if (!
|
|
1247
|
+
if (!skip.test(rel)) rels.push(rel)
|
|
1075
1248
|
}
|
|
1076
1249
|
const ignored = await gitIgnored(cwd, rels)
|
|
1077
1250
|
for (const rel of rels) {
|
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.1"
|
|
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
|
+
}
|