@nifrajs/cli 2.7.1 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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 +26 -4
- package/docs/types.json +11 -4
- 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/dist/mcp-http.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The PUBLIC nifra docs MCP - Streamable-HTTP transport exposing the project-INDEPENDENT tools
|
|
3
|
-
* (`nifra_docs`, `nifra_example`, `nifra_types`, `nifra_learn` + the `
|
|
3
|
+
* (`nifra_docs`, `nifra_example`, `nifra_types`, `nifra_learn` + the `nifra_gallery` MCP Apps widget)
|
|
4
4
|
* so any remote AI agent can learn nifra without a local checkout. They read the bundled corpus
|
|
5
5
|
* (llms-full.txt / examples.json / types.json shipped in this package) and ignore `cwd`, so they reuse the
|
|
6
6
|
* exact definitions from {@link projectTools} - one source, no drift.
|
|
@@ -23,14 +23,14 @@ import { loadTypesCorpus } from "./types-search.js";
|
|
|
23
23
|
export { examplesAppTool, examplesWidget } from "./mcp-app-tool.js";
|
|
24
24
|
export { docsTools } from "./mcp-docs-tools.js";
|
|
25
25
|
// Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
|
|
26
|
-
const VERSION = "2.
|
|
26
|
+
const VERSION = "2.8.0";
|
|
27
27
|
const SERVER_INFO = { name: "nifra-docs", version: VERSION };
|
|
28
28
|
// Derive the GET/health tool list from the tools actually served, so the line can never drift from them.
|
|
29
29
|
const docsHealth = (tools) => `nifra docs MCP - POST JSON-RPC 2.0 here (methods: initialize, tools/list, tools/call). Tools: ${tools
|
|
30
30
|
.map((tool) => tool.name)
|
|
31
31
|
.join(", ")}.`;
|
|
32
32
|
/** The project-independent tools, reading the package's bundled corpus from disk (CLI use): the text
|
|
33
|
-
* docs tools plus the `
|
|
33
|
+
* docs tools plus the `nifra_gallery` MCP Apps widget tool. */
|
|
34
34
|
export function publicDocsTools() {
|
|
35
35
|
return [
|
|
36
36
|
...docsTools(loadDocsCorpus, loadExamplesCorpus, loadTypesCorpus),
|
|
@@ -46,7 +46,7 @@ export function respondMcpHttp(request, tools, options = {}) {
|
|
|
46
46
|
return respondMcpHttpCore(request, tools, SERVER_INFO, { health: docsHealth(tools), ...options });
|
|
47
47
|
}
|
|
48
48
|
/** The CLI HTTP handler: serves the disk-backed corpus tools + registers the examples widget's `ui://`
|
|
49
|
-
* resource so MCP Apps hosts can render `
|
|
49
|
+
* resource so MCP Apps hosts can render `nifra_gallery`. (`nifra docs-mcp` / `bun run` this file.) */
|
|
50
50
|
export function handleMcpHttp(request) {
|
|
51
51
|
return respondMcpHttp(request, publicDocsTools(), {
|
|
52
52
|
features: {
|
package/dist/mcp-http.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mcp-http.js","sourceRoot":"","sources":["../src/mcp-http.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAuB,cAAc,IAAI,kBAAkB,EAAE,MAAM,mBAAmB,CAAA;AAC7F,OAAO,EAAgB,OAAO,EAAE,MAAM,uBAAuB,CAAA;AAC7D,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AACjD,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;AAClD,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AACnE,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAC/C,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AAInD,qGAAqG;AACrG,0FAA0F;AAC1F,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AACnE,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAG/C,+FAA+F;AAC/F,MAAM,OAAO,GAAG,OAAO,CAAA;AACvB,MAAM,WAAW,GAAG,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,EAAE,OAAO,EAAE,CAAA;AAC5D,yGAAyG;AACzG,MAAM,UAAU,GAAG,CAAC,KAAgB,EAAU,EAAE,CAC9C,iGAAiG,KAAK;KACnG,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC;KACxB,IAAI,CAAC,IAAI,CAAC,GAAG,CAAA;AAElB;
|
|
1
|
+
{"version":3,"file":"mcp-http.js","sourceRoot":"","sources":["../src/mcp-http.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAuB,cAAc,IAAI,kBAAkB,EAAE,MAAM,mBAAmB,CAAA;AAC7F,OAAO,EAAgB,OAAO,EAAE,MAAM,uBAAuB,CAAA;AAC7D,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AACjD,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;AAClD,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AACnE,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAC/C,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AAInD,qGAAqG;AACrG,0FAA0F;AAC1F,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AACnE,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAG/C,+FAA+F;AAC/F,MAAM,OAAO,GAAG,OAAO,CAAA;AACvB,MAAM,WAAW,GAAG,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,EAAE,OAAO,EAAE,CAAA;AAC5D,yGAAyG;AACzG,MAAM,UAAU,GAAG,CAAC,KAAgB,EAAU,EAAE,CAC9C,iGAAiG,KAAK;KACnG,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC;KACxB,IAAI,CAAC,IAAI,CAAC,GAAG,CAAA;AAElB;+DAC+D;AAC/D,MAAM,UAAU,eAAe;IAC7B,OAAO;QACL,GAAG,SAAS,CAAC,cAAc,EAAE,kBAAkB,EAAE,eAAe,CAAC;QACjE,eAAe,CAAC,kBAAkB,CAAC;KACpC,CAAA;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAC5B,OAAgB,EAChB,KAAgB,EAChB,UAA0B,EAAE;IAE5B,OAAO,kBAAkB,CAAC,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,EAAE,MAAM,EAAE,UAAU,CAAC,KAAK,CAAC,EAAE,GAAG,OAAO,EAAE,CAAC,CAAA;AACnG,CAAC;AAED;sGACsG;AACtG,MAAM,UAAU,aAAa,CAAC,OAAgB;IAC5C,OAAO,cAAc,CAAC,OAAO,EAAE,eAAe,EAAE,EAAE;QAChD,QAAQ,EAAE;YACR,SAAS,EAAE,CAAC,cAAc,CAAC,QAAQ,CAAC;YACpC,EAAE,EAAE,EAAE,SAAS,EAAE,CAAC,OAAO,CAAC,EAAE;YAC5B,YAAY,EACV,wMAAwM;SAC3M;KACF,CAAC,CAAA;AACJ,CAAC;AAED;;;;;GAKG;AACH,eAAe;IACb,IAAI,EAAE,OAAO,GAAG,KAAK,WAAW,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI;IACtE,KAAK,EAAE,aAAa;CACrB,CAAA"}
|
package/docs/llms-full.txt
CHANGED
|
@@ -842,10 +842,11 @@ one stub per export, so the bodies - and every module they import, including you
|
|
|
842
842
|
stay on the server. Both pipelines do this identically, and the Bun and Vite transforms are
|
|
843
843
|
held to byte-identical output by a parity test.
|
|
844
844
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
845
|
+
`nifra dev --bun` applies it too. Bun's dev-server bundler accepts plugins only
|
|
846
|
+
through bunfig's `[serve.static]` channel, so that command generates a config
|
|
847
|
+
under `.nifra/dev-bun/` carrying the same stub plugin and relaunches itself with
|
|
848
|
+
`--config=` pointing at it - verified per launch, refusing to serve if the
|
|
849
|
+
boundary cannot be proven active. Identical stubs across all three pipelines.
|
|
849
850
|
|
|
850
851
|
### It is a route, so assurance applies
|
|
851
852
|
|
|
@@ -3861,6 +3862,27 @@ One gap: **CSS Modules**. Bun's dev-server bundler has no
|
|
|
3861
3862
|
refuses `--bun` for a CSS-Modules app rather than serving a broken client. Plain CSS
|
|
3862
3863
|
and Tailwind work normally.
|
|
3863
3864
|
|
|
3865
|
+
**
|
|
3866
|
+
Server functions and `*.server` modules work under `--bun`
|
|
3867
|
+
**
|
|
3868
|
+
- and the plumbing is worth knowing. The client build strips them: a `*.fn` module
|
|
3869
|
+
is replaced with an RPC stub and a `*.server` module is emptied, so their bodies
|
|
3870
|
+
(DB handles, secrets, imports) never reach a browser. Bun's dev-server bundling accepts
|
|
3871
|
+
plugins only through `bunfig.toml` (`[serve.static] plugins`) - not
|
|
3872
|
+
programmatically, and a runtime `Bun.plugin` never reaches it (upstream ask:
|
|
3873
|
+
oven-sh/bun#36830). So `nifra dev --bun` generates a config under
|
|
3874
|
+
`.nifra/dev-bun/` carrying the _same_ production boundary plugins, merges
|
|
3875
|
+
your own bunfig's `[serve.static] plugins` and `preload` entries, and
|
|
3876
|
+
re-launches itself once with `--config=` pointing at it. Same stubs as
|
|
3877
|
+
`nifra build`, byte for byte - one implementation, three pipelines.
|
|
3878
|
+
|
|
3879
|
+
Two pipelines, one contract: what keeps them honest is not hope but guards. Both loops serve
|
|
3880
|
+
`public/` through the same handler production uses, the build dedupes the UI
|
|
3881
|
+
framework to one physical copy on both paths, SSR verifies at render time that the renderer
|
|
3882
|
+
and your components share one core (naming both directories if not), and
|
|
3883
|
+
`nifra doctor` flags a stale workspace `dist` before it can shadow
|
|
3884
|
+
source. A divergence between the loops is treated as a bug, not a caveat.
|
|
3885
|
+
|
|
3864
3886
|
```ts
|
|
3865
3887
|
// doc-check: skip - fragment: routesDir/outDir/clientModule/createApp are your app's dev config.
|
|
3866
3888
|
// dev.ts - Bun-native HMR, no Vite in the process
|
package/docs/types.json
CHANGED
|
@@ -958,6 +958,13 @@
|
|
|
958
958
|
"package": "@nifrajs/web",
|
|
959
959
|
"signature": "export interface BuildDiagnosticOptions {\n readonly request?: {\n readonly method: string;\n readonly url: string;\n };\n /** Project root; frames outside it are treated as non-user. Defaults to `process.cwd()`. */\n readonly root?: string;\n /** Injectable source reader (tests pass a fake; production reads the filesystem). */\n readonly read?: SourceReader;\n}"
|
|
960
960
|
},
|
|
961
|
+
{
|
|
962
|
+
"name": "buildFailureDetail",
|
|
963
|
+
"kind": "function",
|
|
964
|
+
"package": "@nifrajs/web",
|
|
965
|
+
"signature": "export declare function buildFailureDetail(err: unknown): string;",
|
|
966
|
+
"doc": "/**\n * Format a client-build rejection for the guard's report. Bun.build rejects with an AggregateError\n * whose message is just \"Bundle failed\" - the actionable part (which file, which import) lives in\n * `.errors`. Surface it, or the guard reports a failure while hiding the reason.\n */"
|
|
967
|
+
},
|
|
961
968
|
{
|
|
962
969
|
"name": "buildManifest",
|
|
963
970
|
"kind": "function",
|
|
@@ -4157,7 +4164,7 @@
|
|
|
4157
4164
|
"kind": "function",
|
|
4158
4165
|
"package": "@nifrajs/cli",
|
|
4159
4166
|
"signature": "export declare function handleMcpHttp(request: Request): Promise<Response>;",
|
|
4160
|
-
"doc": "/** The CLI HTTP handler: serves the disk-backed corpus tools + registers the examples widget's `ui://`\n * resource so MCP Apps hosts can render `
|
|
4167
|
+
"doc": "/** The CLI HTTP handler: serves the disk-backed corpus tools + registers the examples widget's `ui://`\n * resource so MCP Apps hosts can render `nifra_gallery`. (`nifra docs-mcp` / `bun run` this file.) */"
|
|
4161
4168
|
},
|
|
4162
4169
|
{
|
|
4163
4170
|
"name": "Handler",
|
|
@@ -7071,7 +7078,7 @@
|
|
|
7071
7078
|
"kind": "function",
|
|
7072
7079
|
"package": "@nifrajs/cli",
|
|
7073
7080
|
"signature": "export declare function publicDocsTools(): McpTool[];",
|
|
7074
|
-
"doc": "/** The project-independent tools, reading the package's bundled corpus from disk (CLI use): the text\n * docs tools plus the `
|
|
7081
|
+
"doc": "/** The project-independent tools, reading the package's bundled corpus from disk (CLI use): the text\n * docs tools plus the `nifra_gallery` MCP Apps widget tool. */"
|
|
7075
7082
|
},
|
|
7076
7083
|
{
|
|
7077
7084
|
"name": "publicEnvDefines",
|
|
@@ -10814,14 +10821,14 @@
|
|
|
10814
10821
|
"name": "VERSION",
|
|
10815
10822
|
"kind": "const",
|
|
10816
10823
|
"package": "@nifrajs/core",
|
|
10817
|
-
"signature": "export declare const VERSION: \"2.
|
|
10824
|
+
"signature": "export declare const VERSION: \"2.8.0\";",
|
|
10818
10825
|
"doc": "/**\n * Current package version. A hardcoded literal on purpose - core runs on the edge (no fs), so it can't\n * read its own package.json at runtime. `scripts/version.ts` rewrites it on every release bump and\n * `check:publish` asserts it equals `@nifrajs/core`'s package version.\n */"
|
|
10819
10826
|
},
|
|
10820
10827
|
{
|
|
10821
10828
|
"name": "VERSION",
|
|
10822
10829
|
"kind": "const",
|
|
10823
10830
|
"package": "nifra",
|
|
10824
|
-
"signature": "export declare const VERSION: \"2.
|
|
10831
|
+
"signature": "export declare const VERSION: \"2.8.0\";",
|
|
10825
10832
|
"doc": "/**\n * Current package version. A hardcoded literal on purpose - core runs on the edge (no fs), so it can't\n * read its own package.json at runtime. `scripts/version.ts` rewrites it on every release bump and\n * `check:publish` asserts it equals `@nifrajs/core`'s package version.\n */"
|
|
10826
10833
|
},
|
|
10827
10834
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/cli",
|
|
3
|
-
"version": "2.
|
|
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({
|