@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/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, or tests (which legitimately drive `fetch`).
49
- // `dist(-<runtime>)?` also covers per-runtime output dirs (dist-bun/dist-node/dist-deno/dist-vercel).
50
- const IGNORED =
51
- /(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\/|\.(test|spec)\.[cm]?[jt]sx?$/
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(ts: TypeScriptApi, node: TSApi.Expression): SqlExpressionShape {
516
- if (ts.isParenthesizedExpression(node)) return sqlExpressionShape(ts, node.expression)
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 staticText = ts.isTemplateExpression(template)
533
- ? [template.head.text, ...template.templateSpans.map((span) => span.literal.text)].join("")
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: ts.isTemplateExpression(template) && !parameterizedTag,
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: [node.head.text, ...node.templateSpans.map((span) => span.literal.text)].join(""),
559
- dynamic: true,
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
- dynamic: left.dynamic || right.dynamic || !left.literal || !right.literal,
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/build/tests), calling `visit` per file.
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 (!IGNORED.test(rel)) rels.push(rel)
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.7.1"
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
+ }