@nifrajs/cli 3.5.0 → 4.0.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/README.md +42 -3
- package/dist/app-files.d.ts +11 -0
- package/dist/app-files.d.ts.map +1 -0
- package/dist/app-files.js +34 -0
- package/dist/app-files.js.map +1 -0
- package/dist/assure-hydration.d.ts +7 -1
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +128 -43
- package/dist/assure-hydration.js.map +1 -1
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +16 -2
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts +5 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +32 -10
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/cdn-check.d.ts +41 -0
- package/dist/cdn-check.d.ts.map +1 -0
- package/dist/cdn-check.js +207 -0
- package/dist/cdn-check.js.map +1 -0
- package/dist/check-diagnostics.d.ts +4 -0
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +13 -0
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +49 -7
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +291 -62
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +99 -63
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +42 -16
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +262 -82
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +57 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +270 -26
- package/dist/command-catalog.js.map +1 -1
- package/dist/config-target.d.ts +8 -0
- package/dist/config-target.d.ts.map +1 -0
- package/dist/config-target.js +45 -0
- package/dist/config-target.js.map +1 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +52 -13
- package/dist/contracts.js.map +1 -1
- package/dist/db-audit.d.ts +37 -0
- package/dist/db-audit.d.ts.map +1 -0
- package/dist/db-audit.js +99 -0
- package/dist/db-audit.js.map +1 -0
- package/dist/db-child.d.ts +64 -0
- package/dist/db-child.d.ts.map +1 -0
- package/dist/db-child.js +213 -0
- package/dist/db-child.js.map +1 -0
- package/dist/db-config.d.ts +60 -0
- package/dist/db-config.d.ts.map +1 -0
- package/dist/db-config.js +176 -0
- package/dist/db-config.js.map +1 -0
- package/dist/db-tool.d.ts +84 -0
- package/dist/db-tool.d.ts.map +1 -0
- package/dist/db-tool.js +537 -0
- package/dist/db-tool.js.map +1 -0
- package/dist/dev-bun-config.d.ts +20 -13
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +98 -41
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/dev-feed-tool.d.ts +106 -0
- package/dist/dev-feed-tool.d.ts.map +1 -0
- package/dist/dev-feed-tool.js +713 -0
- package/dist/dev-feed-tool.js.map +1 -0
- package/dist/dev-server-client.d.ts +67 -0
- package/dist/dev-server-client.d.ts.map +1 -0
- package/dist/dev-server-client.js +195 -0
- package/dist/dev-server-client.js.map +1 -0
- package/dist/diff-tool.d.ts +3 -3
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +6 -5
- package/dist/diff-tool.js.map +1 -1
- package/dist/docs-search.d.ts.map +1 -1
- package/dist/docs-search.js +12 -4
- package/dist/docs-search.js.map +1 -1
- package/dist/doctor.d.ts +17 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +76 -15
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +15 -0
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +73 -1
- package/dist/env-file.js.map +1 -1
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +41 -21
- package/dist/fix-recipes.js.map +1 -1
- package/dist/frontend-guidance.js +5 -5
- package/dist/frontend-guidance.js.map +1 -1
- package/dist/i18n-check.d.ts +16 -0
- package/dist/i18n-check.d.ts.map +1 -0
- package/dist/i18n-check.js +154 -0
- package/dist/i18n-check.js.map +1 -0
- package/dist/init-agents.d.ts +49 -13
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +239 -34
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/code-unit-order.d.ts +4 -0
- package/dist/internal/code-unit-order.d.ts.map +1 -0
- package/dist/internal/code-unit-order.js +6 -0
- package/dist/internal/code-unit-order.js.map +1 -0
- package/dist/internal/script-kind.d.ts +7 -0
- package/dist/internal/script-kind.d.ts.map +1 -0
- package/dist/internal/script-kind.js +12 -0
- package/dist/internal/script-kind.js.map +1 -0
- package/dist/internal/source-facts.d.ts +5 -0
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +61 -5
- package/dist/internal/source-facts.js.map +1 -1
- package/dist/internal/typescript-import.d.ts +49 -24
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +507 -67
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +5 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +92 -27
- package/dist/introspect.js.map +1 -1
- package/dist/learn.js +3 -3
- package/dist/learn.js.map +1 -1
- package/dist/levels-tool.d.ts +10 -0
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +33 -3
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +67 -17
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +78 -18
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +3 -2
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-app-tool.d.ts.map +1 -1
- package/dist/mcp-app-tool.js +2 -0
- package/dist/mcp-app-tool.js.map +1 -1
- package/dist/mcp-context.js +1 -1
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-delegate.d.ts +57 -0
- package/dist/mcp-delegate.d.ts.map +1 -0
- package/dist/mcp-delegate.js +130 -0
- package/dist/mcp-delegate.js.map +1 -0
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +23 -5
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-exec.d.ts +2 -0
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +132 -108
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.d.ts.map +1 -1
- package/dist/mcp-http.js +17 -5
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp-io.d.ts +14 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +45 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-isolate.d.ts +48 -0
- package/dist/mcp-isolate.d.ts.map +1 -0
- package/dist/mcp-isolate.js +222 -0
- package/dist/mcp-isolate.js.map +1 -0
- package/dist/mcp-project-child.d.ts +74 -0
- package/dist/mcp-project-child.d.ts.map +1 -0
- package/dist/mcp-project-child.js +130 -0
- package/dist/mcp-project-child.js.map +1 -0
- package/dist/mcp-render.js +2 -2
- package/dist/mcp-render.js.map +1 -1
- package/dist/mcp-root.d.ts +37 -9
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +110 -23
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp-run.d.ts +18 -1
- package/dist/mcp-run.d.ts.map +1 -1
- package/dist/mcp-run.js +82 -9
- package/dist/mcp-run.js.map +1 -1
- package/dist/mcp-ws.js +4 -1
- package/dist/mcp-ws.js.map +1 -1
- package/dist/mcp.d.ts +16 -3
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +81 -53
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +44 -0
- package/dist/migrate-layout.d.ts.map +1 -0
- package/dist/migrate-layout.js +982 -0
- package/dist/migrate-layout.js.map +1 -0
- package/dist/openapi-tool.d.ts +2 -2
- package/dist/openapi-tool.d.ts.map +1 -1
- package/dist/openapi-tool.js.map +1 -1
- package/dist/openapi-types.d.ts +2 -1
- package/dist/openapi-types.d.ts.map +1 -1
- package/dist/openapi-types.js +240 -12
- package/dist/openapi-types.js.map +1 -1
- package/dist/pipeline-report.d.ts +3 -2
- package/dist/pipeline-report.d.ts.map +1 -1
- package/dist/pipeline-report.js +19 -16
- package/dist/pipeline-report.js.map +1 -1
- package/dist/port.d.ts +5 -4
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +46 -29
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +5 -0
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js.map +1 -1
- package/dist/recipes/4.0.0.d.ts +9 -0
- package/dist/recipes/4.0.0.d.ts.map +1 -0
- package/dist/recipes/4.0.0.js +34 -0
- package/dist/recipes/4.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/release-verification.d.ts +11 -1
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +47 -4
- package/dist/release-verification.js.map +1 -1
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +12 -8
- package/dist/review.js.map +1 -1
- package/dist/route-types.d.ts +28 -0
- package/dist/route-types.d.ts.map +1 -0
- package/dist/route-types.js +106 -0
- package/dist/route-types.js.map +1 -0
- package/dist/rules/codes.d.ts +9 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +9 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/data-guard.d.ts +12 -0
- package/dist/rules/data-guard.d.ts.map +1 -0
- package/dist/rules/data-guard.js +213 -0
- package/dist/rules/data-guard.js.map +1 -0
- package/dist/rules/index.d.ts +3 -0
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js +17 -5
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/islands.d.ts.map +1 -1
- package/dist/rules/islands.js +7 -4
- package/dist/rules/islands.js.map +1 -1
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +71 -38
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/nano.d.ts.map +1 -1
- package/dist/rules/nano.js +15 -6
- package/dist/rules/nano.js.map +1 -1
- package/dist/rules/pages.d.ts +15 -0
- package/dist/rules/pages.d.ts.map +1 -0
- package/dist/rules/pages.js +88 -0
- package/dist/rules/pages.js.map +1 -0
- package/dist/rules/routes.d.ts +11 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +180 -2
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/secrets.d.ts +10 -0
- package/dist/rules/secrets.d.ts.map +1 -0
- package/dist/rules/secrets.js +169 -0
- package/dist/rules/secrets.js.map +1 -0
- package/dist/rules/security.d.ts +7 -0
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +171 -65
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/typescript.d.ts +16 -0
- package/dist/rules/typescript.d.ts.map +1 -0
- package/dist/rules/typescript.js +59 -0
- package/dist/rules/typescript.js.map +1 -0
- package/dist/rules/zones.d.ts +17 -0
- package/dist/rules/zones.d.ts.map +1 -0
- package/dist/rules/zones.js +174 -0
- package/dist/rules/zones.js.map +1 -0
- package/dist/scaffold.d.ts +25 -12
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +132 -87
- package/dist/scaffold.js.map +1 -1
- package/dist/sdk.d.ts +17 -3
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +941 -153
- package/dist/sdk.js.map +1 -1
- package/dist/search-terms.d.ts +2 -0
- package/dist/search-terms.d.ts.map +1 -1
- package/dist/search-terms.js +23 -8
- package/dist/search-terms.js.map +1 -1
- package/dist/smoke.d.ts +79 -0
- package/dist/smoke.d.ts.map +1 -0
- package/dist/smoke.js +494 -0
- package/dist/smoke.js.map +1 -0
- package/dist/sync-manifest.d.ts.map +1 -1
- package/dist/sync-manifest.js +2 -1
- package/dist/sync-manifest.js.map +1 -1
- package/dist/sync-routes.d.ts +4 -3
- package/dist/sync-routes.d.ts.map +1 -1
- package/dist/sync-routes.js +4 -3
- package/dist/sync-routes.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +30 -11
- package/dist/test-tool.js.map +1 -1
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +11 -5
- package/dist/types-search.js.map +1 -1
- package/dist/upgrade.d.ts +36 -6
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +167 -29
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +4 -0
- package/dist/verification-plan.js.map +1 -1
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +45 -12
- package/dist/verification.js.map +1 -1
- package/dist/web-app-options.d.ts +19 -0
- package/dist/web-app-options.d.ts.map +1 -0
- package/dist/web-app-options.js +49 -0
- package/dist/web-app-options.js.map +1 -0
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +29 -13
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +185 -29
- package/docs/llms-full.txt +3807 -580
- package/docs/types.json +14229 -10939
- package/package.json +19 -12
- package/src/app-files.ts +40 -0
- package/src/assure-hydration.ts +139 -46
- package/src/assure.ts +17 -2
- package/src/capabilities-tool.ts +35 -9
- package/src/cdn-check.ts +268 -0
- package/src/check-diagnostics.ts +17 -0
- package/src/check-scan.ts +358 -63
- package/src/check.ts +97 -62
- package/src/cli.ts +296 -87
- package/src/command-catalog.ts +374 -38
- package/src/config-target.ts +47 -0
- package/src/contracts.ts +54 -12
- package/src/db-audit.ts +128 -0
- package/src/db-child.ts +285 -0
- package/src/db-config.ts +224 -0
- package/src/db-tool.ts +690 -0
- package/src/dev-bun-config.ts +116 -40
- package/src/dev-feed-tool.ts +912 -0
- package/src/dev-server-client.ts +267 -0
- package/src/diff-tool.ts +6 -5
- package/src/docs-search.ts +10 -3
- package/src/doctor.ts +98 -15
- package/src/env-file.ts +78 -1
- package/src/fix-recipes.ts +44 -31
- package/src/frontend-guidance.ts +5 -5
- package/src/i18n-check.ts +179 -0
- package/src/init-agents.ts +301 -38
- package/src/internal/code-unit-order.ts +5 -0
- package/src/internal/script-kind.ts +11 -0
- package/src/internal/source-facts.ts +96 -22
- package/src/internal/typescript-import.ts +663 -68
- package/src/introspect.ts +90 -27
- package/src/learn.ts +3 -3
- package/src/levels-tool.ts +47 -2
- package/src/load.ts +140 -33
- package/src/manifest-tool.ts +3 -2
- package/src/mcp-app-tool.ts +2 -0
- package/src/mcp-context.ts +1 -1
- package/src/mcp-delegate.ts +162 -0
- package/src/mcp-docs-tools.ts +23 -5
- package/src/mcp-exec.ts +160 -132
- package/src/mcp-http.ts +19 -5
- package/src/mcp-io.ts +57 -0
- package/src/mcp-isolate.ts +287 -0
- package/src/mcp-project-child.ts +182 -0
- package/src/mcp-render.ts +2 -2
- package/src/mcp-root.ts +117 -22
- package/src/mcp-run.ts +104 -10
- package/src/mcp-ws.ts +4 -1
- package/src/mcp.ts +106 -64
- package/src/migrate-layout.ts +1121 -0
- package/src/openapi-tool.ts +7 -3
- package/src/openapi-types.ts +315 -13
- package/src/pipeline-report.ts +22 -18
- package/src/port.ts +53 -30
- package/src/project-facts.ts +5 -0
- package/src/recipes/4.0.0.ts +35 -0
- package/src/recipes/index.ts +2 -0
- package/src/release-verification.ts +74 -5
- package/src/review.ts +13 -8
- package/src/route-types.ts +133 -0
- package/src/rules/codes.ts +9 -0
- package/src/rules/data-guard.ts +230 -0
- package/src/rules/index.ts +18 -5
- package/src/rules/islands.ts +12 -4
- package/src/rules/legacy.ts +71 -39
- package/src/rules/nano.ts +18 -6
- package/src/rules/pages.ts +99 -0
- package/src/rules/routes.ts +181 -2
- package/src/rules/secrets.ts +174 -0
- package/src/rules/security.ts +198 -70
- package/src/rules/typescript.ts +77 -0
- package/src/rules/zones.ts +195 -0
- package/src/scaffold.ts +147 -95
- package/src/sdk.ts +1215 -158
- package/src/search-terms.ts +24 -7
- package/src/smoke.ts +607 -0
- package/src/sync-manifest.ts +2 -1
- package/src/sync-routes.ts +4 -3
- package/src/test-tool.ts +32 -11
- package/src/types-search.ts +11 -4
- package/src/upgrade.ts +205 -25
- package/src/verification-plan.ts +13 -0
- package/src/verification.ts +50 -12
- package/src/web-app-options.ts +57 -0
- package/src/work-graph.ts +27 -13
package/src/introspect.ts
CHANGED
|
@@ -12,9 +12,12 @@ import {
|
|
|
12
12
|
reflectedRoutesFromEvidence,
|
|
13
13
|
snapshotProjectEvidence,
|
|
14
14
|
} from "@nifrajs/core/evidence"
|
|
15
|
+
import { compileRoutePattern, type RoutePatternSegment } from "@nifrajs/core/pattern"
|
|
15
16
|
import type { ReflectedRoute } from "@nifrajs/core/reflection"
|
|
16
17
|
import type { Manifest } from "@nifrajs/web"
|
|
17
18
|
import { discoverRoutes } from "@nifrajs/web/fs"
|
|
19
|
+
import { normalizeMountPath } from "@nifrajs/web/route-manifest"
|
|
20
|
+
import { codeUnitOrder } from "./internal/code-unit-order.ts"
|
|
18
21
|
import type { LoadedApp } from "./load.ts"
|
|
19
22
|
import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
|
|
20
23
|
|
|
@@ -102,6 +105,44 @@ function schemaLines(schema: ReflectedRoute["schema"]): string[] {
|
|
|
102
105
|
|
|
103
106
|
const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
|
104
107
|
|
|
108
|
+
/** The verbs the typed client has a terminal call for. */
|
|
109
|
+
const TYPED_VERBS: ReadonlySet<string> = new Set([
|
|
110
|
+
"get",
|
|
111
|
+
"post",
|
|
112
|
+
"put",
|
|
113
|
+
"patch",
|
|
114
|
+
"delete",
|
|
115
|
+
"head",
|
|
116
|
+
"options",
|
|
117
|
+
])
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The call a non-static segment adds to the chain, or `undefined` for a static one. The segment is
|
|
121
|
+
* read by the router's own grammar, so the spelling matches what the typed client accepts:
|
|
122
|
+
* - a whole param or a wildcard is called by its name - `:id` and `:id{[0-9]+}` are `({ id })`;
|
|
123
|
+
* - a segment that is part literal, part parameter is called with the segment text -
|
|
124
|
+
* `:name.json` is ``(`${name}.json`)``.
|
|
125
|
+
*/
|
|
126
|
+
function paramCall(seg: string): string | undefined {
|
|
127
|
+
let segment: RoutePatternSegment | undefined
|
|
128
|
+
try {
|
|
129
|
+
segment = compileRoutePattern(`/${seg}`).segments[0]
|
|
130
|
+
} catch {
|
|
131
|
+
return undefined
|
|
132
|
+
}
|
|
133
|
+
if (segment === undefined || segment.kind === "static") return undefined
|
|
134
|
+
if (segment.kind === "param") return `({ ${segment.name} })`
|
|
135
|
+
if (segment.kind === "wildcard") {
|
|
136
|
+
return segment.name === "*" ? '({ "*": rest })' : `({ ${segment.name} })`
|
|
137
|
+
}
|
|
138
|
+
const [only] = segment.parts
|
|
139
|
+
if (segment.parts.length === 1 && only?.t === "param") return `({ ${only.name} })`
|
|
140
|
+
const text = segment.parts
|
|
141
|
+
.map((part) => (part.t === "lit" ? part.v.replace(/[`\\]|\$\{/g, "\\$&") : `\${${part.name}}`))
|
|
142
|
+
.join("")
|
|
143
|
+
return `(\`${text}\`)`
|
|
144
|
+
}
|
|
145
|
+
|
|
105
146
|
/**
|
|
106
147
|
* The typed-client proxy chain for a path, without the terminal verb call. Shared by `clientCall`
|
|
107
148
|
* and the `nifra routes` collision annotation so both teach exactly one spelling.
|
|
@@ -111,10 +152,9 @@ function clientChain(path: string): string {
|
|
|
111
152
|
if (segs.length === 0) return "api.index"
|
|
112
153
|
let chain = "api"
|
|
113
154
|
for (const seg of segs) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
} else if (reservedKeyFor(seg) !== undefined) chain += `(${JSON.stringify(seg)})`
|
|
155
|
+
const call = paramCall(seg)
|
|
156
|
+
if (call !== undefined) chain += call
|
|
157
|
+
else if (reservedKeyFor(seg) !== undefined) chain += `(${JSON.stringify(seg)})`
|
|
118
158
|
else chain += IDENT.test(seg) ? `.${seg}` : `[${JSON.stringify(seg)}]`
|
|
119
159
|
}
|
|
120
160
|
return chain
|
|
@@ -124,7 +164,8 @@ function clientChain(path: string): string {
|
|
|
124
164
|
* The typed-client call form for a route - the exact `client<typeof app>` proxy chain an agent should
|
|
125
165
|
* write, derived from the same convention `@nifrajs/client` implements (so it never has to read the
|
|
126
166
|
* client tests to learn it): a static segment is a property (`.users`), a path param/wildcard is a call
|
|
127
|
-
* that appends the value (`({ id })`),
|
|
167
|
+
* that appends the value (`({ id })`), a segment that is part literal, part parameter is a call with
|
|
168
|
+
* the segment text (``(`${name}.json`)``), the root path is `.index`, and the HTTP verb is the terminal call.
|
|
128
169
|
* Body verbs (POST/PUT/PATCH) take the body first then call-options; other verbs take call-options first -
|
|
129
170
|
* so the `{ query }` argument lands in the right slot for each.
|
|
130
171
|
*
|
|
@@ -136,6 +177,9 @@ function clientChain(path: string): string {
|
|
|
136
177
|
export function clientCall(method: string, path: string, schema: unknown): string {
|
|
137
178
|
const s = schema as { body?: unknown; query?: unknown } | undefined
|
|
138
179
|
const verb = method.toLowerCase()
|
|
180
|
+
// The typed client has a call for the standard methods only; any other method goes out through
|
|
181
|
+
// `fetch`, against the route's own path.
|
|
182
|
+
if (!TYPED_VERBS.has(verb)) return `await fetch(url, { method: ${JSON.stringify(method)} })`
|
|
139
183
|
const chain = clientChain(path)
|
|
140
184
|
const isBodyVerb = verb === "post" || verb === "put" || verb === "patch"
|
|
141
185
|
let call: string
|
|
@@ -153,16 +197,18 @@ export function clientCall(method: string, path: string, schema: unknown): strin
|
|
|
153
197
|
/** Markdown section listing the backend's API routes with their request + response field shapes. */
|
|
154
198
|
export function apiRoutesSection(routes: readonly ReflectedRoute[]): string {
|
|
155
199
|
if (routes.length === 0) {
|
|
156
|
-
return "## API routes\n\nNo `backend.ts` server routes found (this app may be frontend-only)."
|
|
200
|
+
return "## API routes\n\nNo `backend/app.ts` server routes found (this app may be frontend-only)."
|
|
157
201
|
}
|
|
158
202
|
const lines = [...routes]
|
|
159
|
-
.sort((a, b) => a.path
|
|
203
|
+
.sort((a, b) => codeUnitOrder(a.path, b.path) || codeUnitOrder(a.method, b.method))
|
|
160
204
|
.flatMap((r) => [
|
|
161
205
|
`- \`${r.method} ${r.path}\``,
|
|
162
206
|
...schemaLines(r.schema),
|
|
163
|
-
|
|
207
|
+
TYPED_VERBS.has(r.method.toLowerCase())
|
|
208
|
+
? ` - call: \`${clientCall(r.method, r.path, r.schema)}\` → \`{ ok, status, data, error }\``
|
|
209
|
+
: ` - call: \`${clientCall(r.method, r.path, r.schema)}\` (no typed-client call for this method; \`url\` is the path above)`,
|
|
164
210
|
])
|
|
165
|
-
return `## API routes (backend.ts)\n\nEach route's \`body\`/\`query\`/\`response\` shape is its contract - the typed client derives request inputs and \`res.data\` from these, so a screen built on \`client<typeof app>\` stays in sync automatically. The \`call\` line is the exact \`client<typeof app>\` form: static path segments are properties, a path param is a call (\`({ id })\`), the verb is the terminal call (body first for POST/PUT/PATCH), and every call returns the never-throwing \`{ ok, status, data, error }\` Result.\n\n${lines.join("\n")}`
|
|
211
|
+
return `## API routes (backend/app.ts)\n\nEach route's \`body\`/\`query\`/\`response\` shape is its contract - the typed client derives request inputs and \`res.data\` from these, so a screen built on \`client<typeof app>\` stays in sync automatically. The \`call\` line is the exact \`client<typeof app>\` form: static path segments are properties, a path param is a call (\`({ id })\`), the verb is the terminal call (body first for POST/PUT/PATCH), and every call returns the never-throwing \`{ ok, status, data, error }\` Result.\n\n${lines.join("\n")}`
|
|
166
212
|
}
|
|
167
213
|
|
|
168
214
|
/** Compact API-routes INDEX for the no-arg `nifra_context` call - `METHOD path` per route, WITHOUT the
|
|
@@ -171,12 +217,12 @@ export function apiRoutesSection(routes: readonly ReflectedRoute[]): string {
|
|
|
171
217
|
* contract via the `path`/`kind` slice, instead of every schema up front. */
|
|
172
218
|
export function apiRoutesIndexSection(routes: readonly ReflectedRoute[]): string {
|
|
173
219
|
if (routes.length === 0) {
|
|
174
|
-
return "## API routes (backend.ts)\n\nNo `backend.ts` server routes found (this app may be frontend-only)."
|
|
220
|
+
return "## API routes (backend/app.ts)\n\nNo `backend/app.ts` server routes found (this app may be frontend-only)."
|
|
175
221
|
}
|
|
176
222
|
const lines = [...routes]
|
|
177
|
-
.sort((a, b) => a.path
|
|
223
|
+
.sort((a, b) => codeUnitOrder(a.path, b.path) || codeUnitOrder(a.method, b.method))
|
|
178
224
|
.map((r) => `- \`${r.method} ${r.path}\``)
|
|
179
|
-
return `## API routes (backend.ts)\n\n${routes.length} route${routes.length === 1 ? "" : "s"}. Call \`nifra_context\` again with \`path\` (a route prefix) and/or \`kind: "api"\` for the body/query/response contracts + the exact \`client<typeof app>\` call form - or \`nifra_routes\` for the same as structured JSON.\n\n${lines.join("\n")}`
|
|
225
|
+
return `## API routes (backend/app.ts)\n\n${routes.length} route${routes.length === 1 ? "" : "s"}. Call \`nifra_context\` again with \`path\` (a route prefix) and/or \`kind: "api"\` for the body/query/response contracts + the exact \`client<typeof app>\` call form - or \`nifra_routes\` for the same as structured JSON.\n\n${lines.join("\n")}`
|
|
180
226
|
}
|
|
181
227
|
|
|
182
228
|
/** Markdown section listing the file-routed pages (URL pattern → source file). */
|
|
@@ -185,7 +231,7 @@ export function pageRoutesSection(manifest: Manifest | undefined): string {
|
|
|
185
231
|
return "## Page routes\n\nNo file routes found under `routes/`."
|
|
186
232
|
}
|
|
187
233
|
const lines = [...manifest.routes]
|
|
188
|
-
.sort((a, b) => a.pattern
|
|
234
|
+
.sort((a, b) => codeUnitOrder(a.pattern, b.pattern))
|
|
189
235
|
.map((r) => `- \`${r.pattern}\` → \`${r.file}\``)
|
|
190
236
|
return `## Page routes (routes/)\n\n${lines.join("\n")}`
|
|
191
237
|
}
|
|
@@ -207,9 +253,9 @@ const CONVENTIONS = `## Conventions (summary)
|
|
|
207
253
|
\`res.data\` from the backend's route types, so the compiler catches any frontend/backend drift. Never
|
|
208
254
|
hand-roll \`fetch\` + ad-hoc response types for an internal API - that's exactly how screens drift. It
|
|
209
255
|
never throws: branch on \`res.ok ? res.data : res.error\`.
|
|
210
|
-
- **Pages:** file-routed under \`routes
|
|
211
|
-
|
|
212
|
-
|
|
256
|
+
- **Pages:** file-routed under \`routes/\`. A route is two files: \`x.tsx\` (browser) and \`x.backend.ts\`
|
|
257
|
+
(\`loader\`, \`action\`, \`middleware\`; server only). Server code lives under \`backend/\`, code both sides
|
|
258
|
+
use under \`shared/\`, components under \`frontend/\`; the browser build refuses backend code outright.
|
|
213
259
|
- \`app.fetch(Request)\` is the universal entry. Full reference: this repo's \`AGENTS.md\`, or \`llms-full.txt\`.`
|
|
214
260
|
|
|
215
261
|
/** Optional narrowing for {@link describeProject} - a path prefix and/or one section. A filtered
|
|
@@ -303,7 +349,7 @@ export function routesToJsonFromEvidence(
|
|
|
303
349
|
routes = routes.filter((r) => r.path.startsWith(pathPrefix))
|
|
304
350
|
}
|
|
305
351
|
return [...routes]
|
|
306
|
-
.sort((a, b) => a.path
|
|
352
|
+
.sort((a, b) => codeUnitOrder(a.path, b.path) || codeUnitOrder(a.method, b.method))
|
|
307
353
|
.map((r) => {
|
|
308
354
|
const s = r.schema
|
|
309
355
|
const body = shape(s?.body)
|
|
@@ -373,12 +419,16 @@ export interface RouteTableInput {
|
|
|
373
419
|
/** The page router's `apiPrefix` (default `/api`); an API route at/under it is auto-mounted. `""`
|
|
374
420
|
* disables the auto-mount, so no API route is marked auto-mounted. */
|
|
375
421
|
readonly apiPrefix?: string
|
|
422
|
+
/** The backend declares its routes without the prefix and is served at `apiPrefix + path`: every API
|
|
423
|
+
* route is auto-mounted, listed at the path a request uses. */
|
|
424
|
+
readonly apiStrip?: boolean
|
|
376
425
|
}
|
|
377
426
|
|
|
378
427
|
/** True when `path` is at or under `prefix` as a path segment boundary (`/api` matches `/api` and
|
|
379
428
|
* `/api/x`, but not `/apiary`). An empty prefix matches nothing (auto-mount disabled). */
|
|
380
429
|
function isUnderPrefix(path: string, prefix: string): boolean {
|
|
381
430
|
if (prefix === "") return false
|
|
431
|
+
if (prefix === "/") return true
|
|
382
432
|
return path === prefix || path.startsWith(`${prefix}/`)
|
|
383
433
|
}
|
|
384
434
|
|
|
@@ -390,7 +440,14 @@ function isUnderPrefix(path: string, prefix: string): boolean {
|
|
|
390
440
|
* joined methods, for stable output.
|
|
391
441
|
*/
|
|
392
442
|
export function buildRouteTable(input: RouteTableInput): RouteTableEntry[] {
|
|
393
|
-
const
|
|
443
|
+
const configured = input.apiPrefix ?? "/api"
|
|
444
|
+
// Read the prefix as the server's mount table does (`/api/` and `/api/*` are `/api`).
|
|
445
|
+
const apiPrefix = configured === "" ? "" : (normalizeMountPath(configured) ?? configured)
|
|
446
|
+
const stripped = input.apiStrip === true && apiPrefix !== ""
|
|
447
|
+
const servedPath = (path: string): string => {
|
|
448
|
+
if (!stripped || apiPrefix === "/") return path
|
|
449
|
+
return path === "/" ? apiPrefix : `${apiPrefix}${path}`
|
|
450
|
+
}
|
|
394
451
|
const rows: RouteTableEntry[] = []
|
|
395
452
|
for (const page of input.pages) {
|
|
396
453
|
const methods = page.hasAction ? ["GET", "POST"] : ["GET"]
|
|
@@ -399,11 +456,12 @@ export function buildRouteTable(input: RouteTableInput): RouteTableEntry[] {
|
|
|
399
456
|
// Collapse API routes that share a path into one row with all its methods (a REST resource).
|
|
400
457
|
const byPath = new Map<string, { methods: Set<string>; autoMounted: boolean }>()
|
|
401
458
|
for (const r of input.api) {
|
|
402
|
-
const
|
|
459
|
+
const path = servedPath(r.path)
|
|
460
|
+
const existing = byPath.get(path)
|
|
403
461
|
if (existing === undefined) {
|
|
404
|
-
byPath.set(
|
|
462
|
+
byPath.set(path, {
|
|
405
463
|
methods: new Set([r.method.toUpperCase()]),
|
|
406
|
-
autoMounted: isUnderPrefix(r.path, apiPrefix),
|
|
464
|
+
autoMounted: stripped || isUnderPrefix(r.path, apiPrefix),
|
|
407
465
|
})
|
|
408
466
|
} else existing.methods.add(r.method.toUpperCase())
|
|
409
467
|
}
|
|
@@ -417,9 +475,9 @@ export function buildRouteTable(input: RouteTableInput): RouteTableEntry[] {
|
|
|
417
475
|
}
|
|
418
476
|
return rows.sort(
|
|
419
477
|
(a, b) =>
|
|
420
|
-
a.path
|
|
421
|
-
a.kind
|
|
422
|
-
a.methods.join(",")
|
|
478
|
+
codeUnitOrder(a.path, b.path) ||
|
|
479
|
+
codeUnitOrder(a.kind, b.kind) ||
|
|
480
|
+
codeUnitOrder(a.methods.join(","), b.methods.join(",")),
|
|
423
481
|
)
|
|
424
482
|
}
|
|
425
483
|
|
|
@@ -447,7 +505,7 @@ export function clientSpellingFor(
|
|
|
447
505
|
/** Render the route table as a terse aligned text table (the `nifra routes` default output). Pure. */
|
|
448
506
|
export function renderRouteTable(rows: readonly RouteTableEntry[]): string {
|
|
449
507
|
if (rows.length === 0)
|
|
450
|
-
return "No routes found (no `routes/` pages and no `backend.ts` API routes)."
|
|
508
|
+
return "No routes found (no `routes/` pages and no `backend/app.ts` API routes)."
|
|
451
509
|
const display = rows.map((r) => ({
|
|
452
510
|
methods: r.methods.join(", "),
|
|
453
511
|
kind: r.kind,
|
|
@@ -543,7 +601,7 @@ function parentPath(path: string): string | undefined {
|
|
|
543
601
|
/** Build a stable parent/child graph from the public route table. Pure and topology-neutral. */
|
|
544
602
|
export function buildRouteGraph(rows: readonly RouteTableEntry[]): RouteGraph {
|
|
545
603
|
const sorted = [...rows].sort(
|
|
546
|
-
(a, b) => a.path
|
|
604
|
+
(a, b) => codeUnitOrder(a.path, b.path) || codeUnitOrder(a.kind, b.kind),
|
|
547
605
|
)
|
|
548
606
|
const nodes: RouteGraphNode[] = [
|
|
549
607
|
{ id: "root", kind: "root", path: "/" },
|
|
@@ -643,7 +701,12 @@ async function collectRouteTable(app: LoadedApp): Promise<RouteTableEntry[]> {
|
|
|
643
701
|
}
|
|
644
702
|
pages.push({ pattern: route.pattern, file: route.file, hasAction })
|
|
645
703
|
}
|
|
646
|
-
const rows = buildRouteTable({
|
|
704
|
+
const rows = buildRouteTable({
|
|
705
|
+
pages,
|
|
706
|
+
api: backendRoutes(app.backend),
|
|
707
|
+
...(app.framework.apiPrefix !== undefined ? { apiPrefix: app.framework.apiPrefix } : {}),
|
|
708
|
+
...(app.framework.apiStrip !== undefined ? { apiStrip: app.framework.apiStrip } : {}),
|
|
709
|
+
})
|
|
647
710
|
return rows
|
|
648
711
|
}
|
|
649
712
|
|
package/src/learn.ts
CHANGED
|
@@ -39,7 +39,7 @@ export const LEARN_PATH: readonly LearnStep[] = [
|
|
|
39
39
|
id: "page-route",
|
|
40
40
|
title: "Add a page route",
|
|
41
41
|
goal: "A second page, server-rendered, reachable by URL.",
|
|
42
|
-
do: "Ask nifra_scaffold for the path (e.g. /about) - it
|
|
42
|
+
do: "Ask nifra_scaffold for the path (e.g. /about) - it writes the route pair: the page `routes/about.tsx` (serves `/about`) and its backend half `routes/about.backend.ts`.",
|
|
43
43
|
verify: "nifra_render /about returns the rendered HTML.",
|
|
44
44
|
tools: ["nifra_scaffold", "nifra_render"],
|
|
45
45
|
seeAlso: "page routes file-based routing layouts",
|
|
@@ -48,7 +48,7 @@ export const LEARN_PATH: readonly LearnStep[] = [
|
|
|
48
48
|
id: "loader",
|
|
49
49
|
title: "Load data for a page",
|
|
50
50
|
goal: "A page whose data is fetched on the server before render.",
|
|
51
|
-
do: "Export a `loader` from the route and read
|
|
51
|
+
do: "Export a `loader` and its `loaderOutput` schema from the route's `.backend.ts` half and read the result as `data` (`Route.ComponentProps`) in the page. Get the exact shape from nifra_example ('loader').",
|
|
52
52
|
verify: "nifra_render the page and confirm the loaded data is in the HTML.",
|
|
53
53
|
tools: ["nifra_example", "nifra_render"],
|
|
54
54
|
seeAlso: "loader server data page",
|
|
@@ -57,7 +57,7 @@ export const LEARN_PATH: readonly LearnStep[] = [
|
|
|
57
57
|
id: "api-route",
|
|
58
58
|
title: "Add a typed API route",
|
|
59
59
|
goal: "A backend endpoint with a typed request/response contract.",
|
|
60
|
-
do: "Add the route to `backend.ts` with an @nifrajs/schema body/query/response schema. nifra_context shows the resulting contract and the exact typed-client call form.",
|
|
60
|
+
do: "Add the route to `backend/app.ts` with an @nifrajs/schema body/query/response schema. nifra_context shows the resulting contract and the exact typed-client call form.",
|
|
61
61
|
verify:
|
|
62
62
|
"nifra_run { method, path, body } and check the status + parsed body; nifra_check confirms the contract typechecks.",
|
|
63
63
|
tools: ["nifra_context", "nifra_run", "nifra_check"],
|
package/src/levels-tool.ts
CHANGED
|
@@ -36,6 +36,17 @@ export interface VerificationLevelsResult {
|
|
|
36
36
|
/** Highest level whose whole ladder holds; -1 when even L0 fails. */
|
|
37
37
|
readonly achieved: number
|
|
38
38
|
readonly levels: readonly VerificationLevelStatus[]
|
|
39
|
+
/** Response/error runtime enforcement is reported separately from the cumulative L0-L4 ladder. */
|
|
40
|
+
readonly responseContracts: ResponseContractStatus
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ResponseContractStatus {
|
|
44
|
+
readonly declared: number
|
|
45
|
+
readonly enforced: number
|
|
46
|
+
readonly warnOnly: number
|
|
47
|
+
readonly typedOnly: number
|
|
48
|
+
readonly status: "none" | "partial" | "enforced"
|
|
49
|
+
readonly reasons: readonly string[]
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
export interface CollectLevelsOptions {
|
|
@@ -82,7 +93,7 @@ export async function collectVerificationLevels(
|
|
|
82
93
|
] as const) {
|
|
83
94
|
statuses.push({ level, name, ok: false, reasons: [reason] })
|
|
84
95
|
}
|
|
85
|
-
return finalize(statuses)
|
|
96
|
+
return finalize(statuses, [])
|
|
86
97
|
}
|
|
87
98
|
const config = verification.config
|
|
88
99
|
|
|
@@ -197,11 +208,12 @@ export async function collectVerificationLevels(
|
|
|
197
208
|
reasons: l4Reasons,
|
|
198
209
|
})
|
|
199
210
|
|
|
200
|
-
return finalize(statuses)
|
|
211
|
+
return finalize(statuses, assurance.routes)
|
|
201
212
|
}
|
|
202
213
|
|
|
203
214
|
function finalize(
|
|
204
215
|
statuses: readonly { level: number; name: string; ok: boolean; reasons: string[] }[],
|
|
216
|
+
routes: readonly { readonly responseContract?: "enforce" | "warn" | "unchecked" }[],
|
|
205
217
|
): VerificationLevelsResult {
|
|
206
218
|
// Cumulative: the achieved level is the last rung with every rung below it also holding.
|
|
207
219
|
let achieved = -1
|
|
@@ -209,6 +221,31 @@ function finalize(
|
|
|
209
221
|
if (!status.ok) break
|
|
210
222
|
achieved = status.level
|
|
211
223
|
}
|
|
224
|
+
const declared = routes.filter((route) => route.responseContract !== undefined)
|
|
225
|
+
const enforced = declared.filter((route) => route.responseContract === "enforce").length
|
|
226
|
+
const warnOnly = declared.filter((route) => route.responseContract === "warn").length
|
|
227
|
+
const typedOnly = declared.filter((route) => route.responseContract === "unchecked").length
|
|
228
|
+
const responseContracts: ResponseContractStatus = {
|
|
229
|
+
declared: declared.length,
|
|
230
|
+
enforced,
|
|
231
|
+
warnOnly,
|
|
232
|
+
typedOnly,
|
|
233
|
+
status:
|
|
234
|
+
declared.length === 0 ? "none" : typedOnly === 0 && warnOnly === 0 ? "enforced" : "partial",
|
|
235
|
+
reasons:
|
|
236
|
+
declared.length === 0
|
|
237
|
+
? ["no response/error schemas declared"]
|
|
238
|
+
: [
|
|
239
|
+
...(typedOnly > 0
|
|
240
|
+
? [
|
|
241
|
+
`${typedOnly} declared route(s) are typed-only; install responseContract("enforce")`,
|
|
242
|
+
]
|
|
243
|
+
: []),
|
|
244
|
+
...(warnOnly > 0
|
|
245
|
+
? [`${warnOnly} declared route(s) are warn-only and still send the original payload`]
|
|
246
|
+
: []),
|
|
247
|
+
],
|
|
248
|
+
}
|
|
212
249
|
return Object.freeze({
|
|
213
250
|
achieved,
|
|
214
251
|
levels: Object.freeze(
|
|
@@ -216,6 +253,10 @@ function finalize(
|
|
|
216
253
|
Object.freeze({ ...status, reasons: Object.freeze([...status.reasons]) }),
|
|
217
254
|
),
|
|
218
255
|
),
|
|
256
|
+
responseContracts: Object.freeze({
|
|
257
|
+
...responseContracts,
|
|
258
|
+
reasons: Object.freeze([...responseContracts.reasons]),
|
|
259
|
+
}),
|
|
219
260
|
})
|
|
220
261
|
}
|
|
221
262
|
|
|
@@ -233,6 +274,10 @@ export async function runLevels(
|
|
|
233
274
|
console.log(`${mark} L${status.level} ${status.name}`)
|
|
234
275
|
for (const reason of status.reasons) console.log(` - ${reason}`)
|
|
235
276
|
}
|
|
277
|
+
console.log(
|
|
278
|
+
`[nifra] response contracts: ${result.responseContracts.status} (${result.responseContracts.enforced} enforced, ${result.responseContracts.warnOnly} warn-only, ${result.responseContracts.typedOnly} typed-only)`,
|
|
279
|
+
)
|
|
280
|
+
for (const reason of result.responseContracts.reasons) console.log(` - ${reason}`)
|
|
236
281
|
console.log(
|
|
237
282
|
result.achieved < 0
|
|
238
283
|
? "[nifra] verification level: none (L0 failing)"
|
package/src/load.ts
CHANGED
|
@@ -1,38 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Zero-config app discovery. The CLI reads a few conventions from the project root:
|
|
3
3
|
* - `routes/` - the file-based routes (per `@nifrajs/web/fs`).
|
|
4
|
-
* - `nifra.config.ts` - the CLI's framework wiring (see {@link NifraFramework});
|
|
5
|
-
* used as a fallback. One of the two is required.
|
|
6
|
-
* - `backend.ts` - exports `backend` (a `@nifrajs/core` server, the contract). Optional.
|
|
4
|
+
* - `nifra.config.ts` - the CLI's framework wiring (see {@link NifraFramework});
|
|
5
|
+
* `backend/framework.ts` is used as a fallback. One of the two is required.
|
|
6
|
+
* - `backend/app.ts` - exports `backend` (a `@nifrajs/core` server, the contract). Optional.
|
|
7
7
|
*
|
|
8
|
-
* Why two filenames: a multi-target app's edge entry (`_worker.ts`) imports `framework.ts`
|
|
9
|
-
* render adapter, so `framework.ts` must stay edge-bundlable - it can't reference
|
|
10
|
-
* SFC compiler (Bun would pull them into the `target:"browser"` worker and fail
|
|
11
|
-
* `nifra.config.ts` holds that build/dev tooling instead: it's imported ONLY
|
|
12
|
-
* Bun), so its Vite plugin + SFC-compiler imports (`@vitejs/plugin-vue`,
|
|
13
|
-
* reach the edge bundle. A simple single-target app with no edge
|
|
8
|
+
* Why two filenames: a multi-target app's edge entry (`_worker.ts`) imports `backend/framework.ts`
|
|
9
|
+
* for the render adapter, so `backend/framework.ts` must stay edge-bundlable - it can't reference
|
|
10
|
+
* Vite plugins or the SFC compiler (Bun would pull them into the `target:"browser"` worker and fail
|
|
11
|
+
* on `child_process`). `nifra.config.ts` holds that build/dev tooling instead: it's imported ONLY
|
|
12
|
+
* by this CLI (which runs on Bun), so its Vite plugin + SFC-compiler imports (`@vitejs/plugin-vue`,
|
|
13
|
+
* `@nifrajs/web-vue/plugin`) never reach the edge bundle. A simple single-target app with no edge
|
|
14
|
+
* build can put it all in `backend/framework.ts`.
|
|
14
15
|
*/
|
|
15
16
|
import { existsSync } from "node:fs"
|
|
16
17
|
import { isAbsolute, resolve } from "node:path"
|
|
17
|
-
import type { CssLoadingMode } from "@nifrajs/web"
|
|
18
|
+
import type { CreateWebAppOptions, CssLoadingMode } from "@nifrajs/web"
|
|
19
|
+
import type { SecretExemption } from "@nifrajs/web/build"
|
|
20
|
+
import { assertCurrentLayout, BACKEND_APP_FILE, CONFIG_FILE, FRAMEWORK_FILE } from "./app-files.ts"
|
|
18
21
|
import { checkPipelineSeparation } from "./pipeline-guard.ts"
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
|
-
* A plugin list - either an array, or a **thunk** that returns one (optionally async). Both forms
|
|
22
|
-
* accepted; {@link resolvePlugins} normalizes them. The thunk is just a convenience for
|
|
23
|
-
* plugin's construction - it does NOT make a plugin edge-safe (Bun.build follows a
|
|
24
|
-
* even from an unused export, so a Vite/compiler import in an edge-bundled file
|
|
25
|
-
* safety comes from WHERE the config lives: keep these fields in
|
|
26
|
-
* the edge-imported `framework.ts`. See the module
|
|
24
|
+
* A plugin list - either an array, or a **thunk** that returns one (optionally async). Both forms
|
|
25
|
+
* are accepted; {@link resolvePlugins} normalizes them. The thunk is just a convenience for
|
|
26
|
+
* deferring a plugin's construction - it does NOT make a plugin edge-safe (Bun.build follows a
|
|
27
|
+
* dynamic `import()` even from an unused export, so a Vite/compiler import in an edge-bundled file
|
|
28
|
+
* fails regardless). Edge safety comes from WHERE the config lives: keep these fields in
|
|
29
|
+
* `nifra.config.ts` (CLI-only), not in the edge-imported `backend/framework.ts`. See the module
|
|
30
|
+
* header.
|
|
27
31
|
*/
|
|
28
32
|
export type PluginsField =
|
|
29
33
|
| readonly unknown[]
|
|
30
34
|
| (() => readonly unknown[] | Promise<readonly unknown[]>)
|
|
31
35
|
|
|
32
36
|
/**
|
|
33
|
-
* The CLI's framework wiring - exported from `nifra.config.ts` (or `framework.ts` as a
|
|
34
|
-
* `create-nifra` generates it. Only `adapter` + `clientModule` are required; the
|
|
35
|
-
* fields are framework-specific extras (Vue/Svelte/Solid need them; React/Preact
|
|
37
|
+
* The CLI's framework wiring - exported from `nifra.config.ts` (or `backend/framework.ts` as a
|
|
38
|
+
* fallback). `create-nifra` generates it. Only `adapter` + `clientModule` are required; the
|
|
39
|
+
* plugin/condition fields are framework-specific extras (Vue/Svelte/Solid need them; React/Preact
|
|
40
|
+
* don't).
|
|
36
41
|
*/
|
|
37
42
|
export interface NifraFramework {
|
|
38
43
|
/** The render adapter, e.g. `reactAdapter` from `@nifrajs/web-react`. */
|
|
@@ -42,7 +47,7 @@ export interface NifraFramework {
|
|
|
42
47
|
/** App-level middleware applied to the web app before its page routes are declared -
|
|
43
48
|
* e.g. `(app) => app.use(securityHeaders())`. Runs ahead of the `api`/`mounts` hooks.
|
|
44
49
|
*
|
|
45
|
-
* MUST be exported from `framework.ts`, not `nifra.config.ts`: `nifra build` emits a
|
|
50
|
+
* MUST be exported from `backend/framework.ts`, not `nifra.config.ts`: `nifra build` emits a
|
|
46
51
|
* server entry that imports it, and that entry is edge-bundled. See the module header. */
|
|
47
52
|
readonly use?: (app: never) => void
|
|
48
53
|
/** Vite plugins for `nifra dev`'s HMR, e.g. `[react()]` / `() => import("@vitejs/plugin-vue")…`. */
|
|
@@ -57,14 +62,88 @@ export interface NifraFramework {
|
|
|
57
62
|
readonly conditions?: readonly string[]
|
|
58
63
|
/** Compile-time `define` replacements, e.g. Vue's `__VUE_*` flags. */
|
|
59
64
|
readonly define?: Readonly<Record<string, string>>
|
|
65
|
+
/** The deploy target `nifra build` emits without `--target` (default `"bun"`); `nifra target <t>`
|
|
66
|
+
* rewrites it. */
|
|
67
|
+
readonly target?: string
|
|
68
|
+
/** `"platform"` makes a `cloudflare` / `vercel` build read `c.clientIp` from the header that
|
|
69
|
+
* platform's edge overwrites (`cf-connecting-ip` / `x-real-ip`). Left out, an edge build has no
|
|
70
|
+
* caller address, so a per-caller `rateLimit` refuses every request. Declare it only while that edge
|
|
71
|
+
* is the sole way in: served any other way the header is whatever the client sent. Self-hosting
|
|
72
|
+
* targets use the socket peer either way. */
|
|
73
|
+
readonly clientIp?: "platform"
|
|
74
|
+
/** `nifra dev` settings. `indicator: false` turns the in-page issues badge off for every run
|
|
75
|
+
* (`nifra dev --no-indicator` does it for one); browser errors still reach `nifra errors`. */
|
|
76
|
+
readonly dev?: { readonly indicator?: boolean }
|
|
60
77
|
/** Static files directory. Defaults to `<app>/public`; `false` disables it. */
|
|
61
78
|
readonly publicDir?: string | false
|
|
62
79
|
/** Client-visible environment prefix (default `"PUBLIC_"`; empty disables exposure). */
|
|
63
80
|
readonly publicEnvPrefix?: string
|
|
81
|
+
/** Reviewed false positives of `nifra build`'s secret scan, each `{ rule, file, reason }` (or
|
|
82
|
+
* `{ rule: "private-env-value", env, reason }`). There is no exemption by value. */
|
|
83
|
+
readonly secretExemptions?: readonly SecretExemption[]
|
|
64
84
|
/** Vite production CSS output policy. `false` emits one aggregate stylesheet for the whole client. */
|
|
65
85
|
readonly cssCodeSplit?: boolean
|
|
66
86
|
/** Framework-owned SSR stylesheet activation. Deferred mode is intended for aggregate CSS. */
|
|
67
87
|
readonly cssLoading?: CssLoadingMode
|
|
88
|
+
/** Path `backend/app.ts`'s backend is served at (default `"/api"`; `""` turns the mount off). A
|
|
89
|
+
* page file under it can never render, so the app refuses to start and `nifra build` refuses to
|
|
90
|
+
* build.
|
|
91
|
+
*
|
|
92
|
+
* The fields from here down reach the generated server entry, so like `use` they MUST be exported
|
|
93
|
+
* from `backend/framework.ts` (re-export them from `nifra.config.ts` when both exist). */
|
|
94
|
+
readonly apiPrefix?: string
|
|
95
|
+
/** Strip {@link apiPrefix} before the backend sees the path, for a backend that declares its routes
|
|
96
|
+
* without it. Default `false`. */
|
|
97
|
+
readonly apiStrip?: boolean
|
|
98
|
+
/** Apps mounted ahead of page routing - an auth handler, a webhook receiver, a backend served at an
|
|
99
|
+
* extra path. See `createWebApp`'s `mounts`. */
|
|
100
|
+
readonly mounts?: CreateWebAppOptions["mounts"]
|
|
101
|
+
/** A hash-based Content-Security-Policy from `createCspPolicy`. Exclusive with {@link nonce}. */
|
|
102
|
+
readonly csp?: CreateWebAppOptions["csp"]
|
|
103
|
+
/** A per-request CSP nonce resolver. Exclusive with {@link csp}. */
|
|
104
|
+
readonly nonce?: CreateWebAppOptions["nonce"]
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The {@link NifraFramework} fields forwarded to `createWebApp` and imported by the generated server
|
|
108
|
+
* entry, in one list so every command that builds the app forwards the same set. */
|
|
109
|
+
export const FRAMEWORK_WEB_OPTIONS = ["apiPrefix", "apiStrip", "mounts", "csp", "nonce"] as const
|
|
110
|
+
export type FrameworkWebOption = (typeof FRAMEWORK_WEB_OPTIONS)[number]
|
|
111
|
+
|
|
112
|
+
/** Refuse a forwarded field of the wrong shape at load, naming the field, rather than letting it fail
|
|
113
|
+
* later inside `createWebApp` or the generated server entry. */
|
|
114
|
+
function assertFrameworkWebOptions(fw: Partial<NifraFramework>, configFile: string): void {
|
|
115
|
+
const wrong = (field: string, expected: string): never => {
|
|
116
|
+
throw new Error(`[nifra] ${configFile}: \`${field}\` must be ${expected}.`)
|
|
117
|
+
}
|
|
118
|
+
if (fw.apiPrefix !== undefined && typeof fw.apiPrefix !== "string") {
|
|
119
|
+
wrong("apiPrefix", 'a path string such as "/api", or "" to turn the backend mount off')
|
|
120
|
+
}
|
|
121
|
+
if (fw.apiStrip !== undefined && typeof fw.apiStrip !== "boolean") wrong("apiStrip", "a boolean")
|
|
122
|
+
if (fw.mounts !== undefined && !Array.isArray(fw.mounts)) {
|
|
123
|
+
wrong("mounts", "an array of { path, app } entries")
|
|
124
|
+
}
|
|
125
|
+
if (fw.csp !== undefined && (typeof fw.csp !== "object" || fw.csp === null)) {
|
|
126
|
+
wrong("csp", "the policy createCspPolicy() returns")
|
|
127
|
+
}
|
|
128
|
+
if (fw.nonce !== undefined && typeof fw.nonce !== "function") {
|
|
129
|
+
wrong("nonce", "a nonce resolver function, such as createNonceResolver() returns")
|
|
130
|
+
}
|
|
131
|
+
if (fw.secretExemptions !== undefined && !Array.isArray(fw.secretExemptions)) {
|
|
132
|
+
wrong("secretExemptions", "an array of { rule, file, reason } entries")
|
|
133
|
+
}
|
|
134
|
+
if (fw.target !== undefined && typeof fw.target !== "string") {
|
|
135
|
+
wrong("target", 'a deploy target name such as "bun"')
|
|
136
|
+
}
|
|
137
|
+
if (fw.clientIp !== undefined && fw.clientIp !== "platform") {
|
|
138
|
+
wrong("clientIp", '"platform" (trust the edge platform\'s client-address header), or left out')
|
|
139
|
+
}
|
|
140
|
+
if (fw.dev !== undefined) {
|
|
141
|
+
if (typeof fw.dev !== "object" || fw.dev === null)
|
|
142
|
+
wrong("dev", "an object such as { indicator: false }")
|
|
143
|
+
const indicator: unknown = Reflect.get(fw.dev, "indicator")
|
|
144
|
+
if (indicator !== undefined && typeof indicator !== "boolean")
|
|
145
|
+
wrong("dev.indicator", "a boolean")
|
|
146
|
+
}
|
|
68
147
|
}
|
|
69
148
|
|
|
70
149
|
/**
|
|
@@ -104,8 +183,9 @@ export interface ResolvedPlugins {
|
|
|
104
183
|
|
|
105
184
|
export interface LoadedApp {
|
|
106
185
|
readonly cwd: string
|
|
107
|
-
/** Absolute path of the config this app was loaded from - `nifra.config.ts`, or
|
|
108
|
-
* fallback. `nifra dev`'s Bun pipeline re-imports it from a
|
|
186
|
+
/** Absolute path of the config this app was loaded from - `nifra.config.ts`, or
|
|
187
|
+
* `backend/framework.ts` as the fallback. `nifra dev`'s Bun pipeline re-imports it from a
|
|
188
|
+
* GENERATED module so the app's own
|
|
109
189
|
* `clientPlugins` reach Bun's dev-server bundler, which takes plugins only as module paths. */
|
|
110
190
|
readonly configPath: string
|
|
111
191
|
readonly routesDir: string
|
|
@@ -114,7 +194,7 @@ export interface LoadedApp {
|
|
|
114
194
|
readonly framework: NifraFramework
|
|
115
195
|
/** Plugin thunks resolved exactly once during app loading and reused by every command phase. */
|
|
116
196
|
readonly resolvedPlugins: ResolvedPlugins
|
|
117
|
-
/** The `backend` export from `backend.ts`, or `undefined` if there's no `backend.ts`. */
|
|
197
|
+
/** The `backend` export from `backend/app.ts`, or `undefined` if there's no `backend/app.ts`. */
|
|
118
198
|
readonly backend: unknown
|
|
119
199
|
}
|
|
120
200
|
|
|
@@ -126,7 +206,8 @@ export interface LoadAppOptions {
|
|
|
126
206
|
/**
|
|
127
207
|
* Root-level monorepo config - exported from `nifra.config.ts` at the workspace root.
|
|
128
208
|
* Each key in `apps` is the short name used to namespace MCP tools (`nifra_<name>_context` etc.);
|
|
129
|
-
* each value is a path relative to the root that contains its own `nifra.config.ts` /
|
|
209
|
+
* each value is a path relative to the root that contains its own `nifra.config.ts` /
|
|
210
|
+
* `backend/framework.ts`.
|
|
130
211
|
*
|
|
131
212
|
* Example:
|
|
132
213
|
* ```ts
|
|
@@ -177,23 +258,48 @@ const isAdapter = (v: unknown): boolean => typeof v === "object" && v !== null
|
|
|
177
258
|
const importWithQuery = (path: string, query: string | undefined): Promise<unknown> =>
|
|
178
259
|
import(query === undefined || query === "" ? path : `${path}?${query}`)
|
|
179
260
|
|
|
180
|
-
/**
|
|
181
|
-
*
|
|
261
|
+
/**
|
|
262
|
+
* What reads only the backend (`nifra openapi`): the whole app when it has a web config, else
|
|
263
|
+
* `backend/app.ts` alone, so an API-only app needs no adapter to describe its routes.
|
|
264
|
+
*/
|
|
265
|
+
export async function loadBackendApp(
|
|
266
|
+
cwd: string,
|
|
267
|
+
options: LoadAppOptions = {},
|
|
268
|
+
): Promise<Pick<LoadedApp, "cwd" | "backend">> {
|
|
269
|
+
if (existsSync(resolve(cwd, CONFIG_FILE)) || existsSync(resolve(cwd, FRAMEWORK_FILE))) {
|
|
270
|
+
return loadApp(cwd, "dist", options)
|
|
271
|
+
}
|
|
272
|
+
assertCurrentLayout(cwd)
|
|
273
|
+
const backendPath = resolve(cwd, BACKEND_APP_FILE)
|
|
274
|
+
if (!existsSync(backendPath)) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`[nifra] no ${BACKEND_APP_FILE} in ${cwd}: the OpenAPI document describes its routes`,
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
const backend = (
|
|
280
|
+
(await importWithQuery(backendPath, options.importQuery)) as { backend?: unknown }
|
|
281
|
+
).backend
|
|
282
|
+
return { cwd, backend }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Discover + validate the app conventions rooted at `cwd`. Prefers `nifra.config.ts`, falls back
|
|
286
|
+
* to
|
|
287
|
+
* `backend/framework.ts`. Throws a clear, actionable error if neither exists or the config is
|
|
288
|
+
* malformed. */
|
|
182
289
|
export async function loadApp(
|
|
183
290
|
cwd: string,
|
|
184
291
|
outDirName = "dist",
|
|
185
292
|
options: LoadAppOptions = {},
|
|
186
293
|
): Promise<LoadedApp> {
|
|
187
294
|
// nifra.config.ts is the CLI's config (it may import Vite plugins / the SFC compiler, which a
|
|
188
|
-
// multi-target app keeps OUT of the edge-imported framework.ts). framework.ts is
|
|
189
|
-
// simple single-target app that has no edge build.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
: "framework.ts"
|
|
295
|
+
// multi-target app keeps OUT of the edge-imported backend/framework.ts). backend/framework.ts is
|
|
296
|
+
// the fallback for a simple single-target app that has no edge build.
|
|
297
|
+
assertCurrentLayout(cwd)
|
|
298
|
+
const configFile = existsSync(resolve(cwd, CONFIG_FILE)) ? CONFIG_FILE : FRAMEWORK_FILE
|
|
193
299
|
const configPath = resolve(cwd, configFile)
|
|
194
300
|
if (!existsSync(configPath)) {
|
|
195
301
|
throw new Error(
|
|
196
|
-
`[nifra] no
|
|
302
|
+
`[nifra] no ${CONFIG_FILE} or ${FRAMEWORK_FILE} found in ${cwd}.\n` +
|
|
197
303
|
" nifra is zero-config but needs one of them exporting at least:\n" +
|
|
198
304
|
" export const adapter = reactAdapter // from @nifrajs/web-react\n" +
|
|
199
305
|
' export const clientModule = "@nifrajs/web-react/client"\n' +
|
|
@@ -211,6 +317,7 @@ export async function loadApp(
|
|
|
211
317
|
`[nifra] ${configFile} must export \`adapter\` (a render adapter object) and \`clientModule\` (a string).`,
|
|
212
318
|
)
|
|
213
319
|
}
|
|
320
|
+
assertFrameworkWebOptions(fw, configFile)
|
|
214
321
|
// A `clientModule` given as a RELATIVE path (a local client entry, e.g. `./src/client.tsx`) is
|
|
215
322
|
// resolved to absolute here. It is embedded verbatim as an import specifier in the generated client
|
|
216
323
|
// entry, and `nifra dev` and `nifra build` write that entry into DIFFERENT directories - so a relative
|
|
@@ -251,7 +358,7 @@ export async function loadApp(
|
|
|
251
358
|
assertPipelineSeparation(resolvedPlugins, configFile)
|
|
252
359
|
|
|
253
360
|
let backend: unknown
|
|
254
|
-
const backendPath = resolve(cwd,
|
|
361
|
+
const backendPath = resolve(cwd, BACKEND_APP_FILE)
|
|
255
362
|
if (existsSync(backendPath)) {
|
|
256
363
|
backend = ((await importWithQuery(backendPath, options.importQuery)) as { backend?: unknown })
|
|
257
364
|
.backend
|