@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/search-terms.ts
CHANGED
|
@@ -22,6 +22,7 @@ const ALIASES: Readonly<Record<string, readonly string[]>> = {
|
|
|
22
22
|
cache: ["caching", "isr"],
|
|
23
23
|
caching: ["cache", "isr"],
|
|
24
24
|
client: ["typed"],
|
|
25
|
+
cookie: ["cookies", "session", "sessions"],
|
|
25
26
|
deploy: ["deployment", "edge", "worker", "workers", "vercel", "deno"],
|
|
26
27
|
deployment: ["deploy", "edge", "worker", "workers", "vercel", "deno"],
|
|
27
28
|
loader: ["loaders"],
|
|
@@ -47,11 +48,18 @@ const ALIASES: Readonly<Record<string, readonly string[]>> = {
|
|
|
47
48
|
export const MAX_QUERY_CHARS = 256
|
|
48
49
|
export const MAX_QUERY_TERMS = 12
|
|
49
50
|
|
|
51
|
+
// A camelCase word also stays whole: "WebSockets" splits to "web" + "sockets", which a
|
|
52
|
+
// "websocket" query never matches.
|
|
53
|
+
const CAMEL_WORD = /[A-Za-z0-9]*[a-z0-9][A-Z][A-Za-z0-9]*/g
|
|
54
|
+
|
|
50
55
|
export const tokenize = (s: string): string[] =>
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
[
|
|
57
|
+
...s
|
|
58
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
59
|
+
.toLowerCase()
|
|
60
|
+
.split(/[^a-z0-9]+/),
|
|
61
|
+
...(s.match(CAMEL_WORD) ?? []).map((word) => word.toLowerCase()),
|
|
62
|
+
].filter((t) => t.length > 1)
|
|
55
63
|
|
|
56
64
|
export function queryTermGroups(query: string): SearchTermGroup[] {
|
|
57
65
|
return [...new Set(tokenize(query.slice(0, MAX_QUERY_CHARS)))]
|
|
@@ -73,12 +81,21 @@ function tokenMatches(term: string, candidate: string): boolean {
|
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
export function tokenSetHas(tokens: ReadonlySet<string>, group: SearchTermGroup): boolean {
|
|
84
|
+
return tokenSetScore(tokens, group) > 0
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Score the strongest name/heading match while keeping exact query words ahead of aliases. */
|
|
88
|
+
export function tokenSetScore(tokens: ReadonlySet<string>, group: SearchTermGroup): number {
|
|
89
|
+
let best = 0
|
|
76
90
|
for (const variant of group.variants) {
|
|
77
|
-
for (const
|
|
78
|
-
if (tokenMatches(variant,
|
|
91
|
+
for (const candidate of tokens) {
|
|
92
|
+
if (!tokenMatches(variant, candidate)) continue
|
|
93
|
+
const score =
|
|
94
|
+
variant === group.term && candidate === variant ? 3 : candidate === variant ? 2 : 1
|
|
95
|
+
if (score > best) best = score
|
|
79
96
|
}
|
|
80
97
|
}
|
|
81
|
-
return
|
|
98
|
+
return best
|
|
82
99
|
}
|
|
83
100
|
|
|
84
101
|
export function countBodyHits(lowerBody: string, group: SearchTermGroup, maxHits: number): number {
|
package/src/smoke.ts
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import { realpath, stat } from "node:fs/promises"
|
|
2
|
+
import { isAbsolute, relative, resolve } from "node:path"
|
|
3
|
+
import { pathToFileURL } from "node:url"
|
|
4
|
+
|
|
5
|
+
export interface SmokeApp {
|
|
6
|
+
fetch(request: Request): Response | Promise<Response>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SmokeServer {
|
|
10
|
+
readonly origin: string
|
|
11
|
+
readonly fetch?: (request: Request) => Response | Promise<Response>
|
|
12
|
+
stop(): void | Promise<void>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SmokeRequestCheck {
|
|
16
|
+
readonly id: string
|
|
17
|
+
readonly path: string
|
|
18
|
+
readonly method?: string
|
|
19
|
+
readonly headers?: Readonly<Record<string, string>>
|
|
20
|
+
readonly body?: string
|
|
21
|
+
readonly expectedStatus: number
|
|
22
|
+
readonly expectedHeaders?: Readonly<Record<string, string>>
|
|
23
|
+
readonly contentType?: "json" | "text"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SmokeSsrCheck {
|
|
27
|
+
readonly path: string
|
|
28
|
+
readonly marker: string
|
|
29
|
+
readonly expectedStatus?: number
|
|
30
|
+
readonly expectedHeaders?: Readonly<Record<string, string>>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SmokeHookContext {
|
|
34
|
+
readonly app: SmokeApp
|
|
35
|
+
readonly origin: string
|
|
36
|
+
request(check: SmokeRequestCheck): Promise<Response>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SmokeContractResult {
|
|
40
|
+
readonly ok: boolean
|
|
41
|
+
readonly failures?: number
|
|
42
|
+
readonly gaps?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SmokeFixture {
|
|
46
|
+
readonly app: SmokeApp
|
|
47
|
+
/** Starts the built production server. Omit only when explicitly using in-process mode. */
|
|
48
|
+
readonly start?: (app: SmokeApp) => SmokeServer | Promise<SmokeServer>
|
|
49
|
+
/** Required SSR document check. */
|
|
50
|
+
readonly ssr?: SmokeSsrCheck
|
|
51
|
+
/** At least one mounted/API witness is required for a green smoke run. */
|
|
52
|
+
readonly mountedApi?: readonly SmokeRequestCheck[]
|
|
53
|
+
/** Defaults to GET /__nifra_smoke_missing__. */
|
|
54
|
+
readonly notFound?: SmokeRequestCheck
|
|
55
|
+
/** Header checks, including CSP/security headers when the application configures them. */
|
|
56
|
+
readonly securityHeaders?: readonly SmokeRequestCheck[]
|
|
57
|
+
/** Optional callback/guard witnesses; credentials stay inside the fixture. */
|
|
58
|
+
readonly authCallbacks?: readonly SmokeRequestCheck[]
|
|
59
|
+
/** Optional response-contract laboratory hook. Its result is summarized without payloads. */
|
|
60
|
+
readonly contract?: (
|
|
61
|
+
context: SmokeHookContext,
|
|
62
|
+
) => SmokeContractResult | Promise<SmokeContractResult>
|
|
63
|
+
/** Optional browser hydration hook supplied by the application/test environment. */
|
|
64
|
+
readonly hydration?: (context: SmokeHookContext) => void | Promise<void>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface SmokeRunOptions {
|
|
68
|
+
/** Allow a deliberately in-process run without a production server start. Default false. */
|
|
69
|
+
readonly inProcess?: boolean
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface SmokeCheckResult {
|
|
73
|
+
readonly id: string
|
|
74
|
+
readonly status: "pass" | "fail" | "skip"
|
|
75
|
+
readonly category:
|
|
76
|
+
| "start"
|
|
77
|
+
| "ssr"
|
|
78
|
+
| "api"
|
|
79
|
+
| "not-found"
|
|
80
|
+
| "headers"
|
|
81
|
+
| "auth"
|
|
82
|
+
| "contract"
|
|
83
|
+
| "hydration"
|
|
84
|
+
readonly message?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface SmokeReport {
|
|
88
|
+
readonly version: 1
|
|
89
|
+
readonly ok: boolean
|
|
90
|
+
readonly mode: "production" | "in-process"
|
|
91
|
+
readonly checks: readonly SmokeCheckResult[]
|
|
92
|
+
readonly counts: {
|
|
93
|
+
readonly passed: number
|
|
94
|
+
readonly failed: number
|
|
95
|
+
readonly skipped: number
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const safeId = (value: string, fallback: string): string =>
|
|
100
|
+
/^[A-Za-z0-9._-]{1,80}$/.test(value) ? value : fallback
|
|
101
|
+
|
|
102
|
+
const MAX_SMOKE_PATH_LENGTH = 4_096
|
|
103
|
+
const MAX_SMOKE_MARKER_LENGTH = 4_096
|
|
104
|
+
const MAX_SMOKE_BODY_LENGTH = 1_048_576
|
|
105
|
+
const MAX_SMOKE_RESPONSE_BODY_LENGTH = 1_048_576
|
|
106
|
+
const MAX_SMOKE_HEADER_NAME_LENGTH = 256
|
|
107
|
+
const MAX_SMOKE_HEADER_VALUE_LENGTH = 8_192
|
|
108
|
+
const HTTP_TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/
|
|
109
|
+
const SCHEME_PREFIX = /^[A-Za-z][A-Za-z0-9+.-]*:/
|
|
110
|
+
|
|
111
|
+
function hasControlCharacter(value: string, includeBackslash = false): boolean {
|
|
112
|
+
for (let index = 0; index < value.length; index++) {
|
|
113
|
+
const code = value.charCodeAt(index)
|
|
114
|
+
if (code <= 0x1f || code === 0x7f || (includeBackslash && code === 0x5c)) return true
|
|
115
|
+
}
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
120
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function validStatus(value: unknown): value is number {
|
|
124
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function validRelativePath(value: unknown): value is string {
|
|
128
|
+
return (
|
|
129
|
+
typeof value === "string" &&
|
|
130
|
+
value.length > 0 &&
|
|
131
|
+
value.length <= MAX_SMOKE_PATH_LENGTH &&
|
|
132
|
+
value.startsWith("/") &&
|
|
133
|
+
!value.startsWith("//") &&
|
|
134
|
+
!SCHEME_PREFIX.test(value) &&
|
|
135
|
+
!hasControlCharacter(value, true)
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function headerError(value: unknown, label: string): string | undefined {
|
|
140
|
+
if (!isRecord(value)) return `${label} must be an object`
|
|
141
|
+
for (const [name, headerValue] of Object.entries(value)) {
|
|
142
|
+
if (name.length === 0 || name.length > MAX_SMOKE_HEADER_NAME_LENGTH || !HTTP_TOKEN.test(name))
|
|
143
|
+
return `${label} contains an invalid header name`
|
|
144
|
+
if (
|
|
145
|
+
typeof headerValue !== "string" ||
|
|
146
|
+
headerValue.length > MAX_SMOKE_HEADER_VALUE_LENGTH ||
|
|
147
|
+
hasControlCharacter(headerValue)
|
|
148
|
+
)
|
|
149
|
+
return `${label} contains an invalid header value`
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
new Headers(value as Record<string, string>)
|
|
153
|
+
} catch {
|
|
154
|
+
return `${label} contains an invalid header`
|
|
155
|
+
}
|
|
156
|
+
return undefined
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function requestCheckError(value: unknown, label: string): string | undefined {
|
|
160
|
+
if (!isRecord(value)) return `${label} must be an object`
|
|
161
|
+
if (typeof value.id !== "string" || !/^[A-Za-z0-9._-]{1,80}$/.test(value.id))
|
|
162
|
+
return `${label}.id must be a short safe identifier`
|
|
163
|
+
if (!validRelativePath(value.path)) return `${label}.path must be a local absolute path`
|
|
164
|
+
if (value.method !== undefined) {
|
|
165
|
+
if (
|
|
166
|
+
typeof value.method !== "string" ||
|
|
167
|
+
value.method.length === 0 ||
|
|
168
|
+
value.method.length > 32 ||
|
|
169
|
+
!HTTP_TOKEN.test(value.method)
|
|
170
|
+
)
|
|
171
|
+
return `${label}.method is invalid`
|
|
172
|
+
}
|
|
173
|
+
if (!validStatus(value.expectedStatus)) return `${label}.expectedStatus is invalid`
|
|
174
|
+
if (value.headers !== undefined) {
|
|
175
|
+
const error = headerError(value.headers, `${label}.headers`)
|
|
176
|
+
if (error !== undefined) return error
|
|
177
|
+
}
|
|
178
|
+
if (value.expectedHeaders !== undefined) {
|
|
179
|
+
const error = headerError(value.expectedHeaders, `${label}.expectedHeaders`)
|
|
180
|
+
if (error !== undefined) return error
|
|
181
|
+
}
|
|
182
|
+
if (
|
|
183
|
+
value.body !== undefined &&
|
|
184
|
+
(typeof value.body !== "string" || value.body.length > MAX_SMOKE_BODY_LENGTH)
|
|
185
|
+
)
|
|
186
|
+
return `${label}.body is invalid or too large`
|
|
187
|
+
if (
|
|
188
|
+
value.contentType !== undefined &&
|
|
189
|
+
value.contentType !== "json" &&
|
|
190
|
+
value.contentType !== "text"
|
|
191
|
+
)
|
|
192
|
+
return `${label}.contentType is invalid`
|
|
193
|
+
const method = typeof value.method === "string" ? value.method.toUpperCase() : "GET"
|
|
194
|
+
if (value.body !== undefined && (method === "GET" || method === "HEAD"))
|
|
195
|
+
return `${label}.body cannot be used with ${method}`
|
|
196
|
+
return undefined
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function smokeSsrError(value: unknown): string | undefined {
|
|
200
|
+
if (!isRecord(value)) return "ssr check must be an object"
|
|
201
|
+
if (!validRelativePath(value.path)) return "ssr.path must be a local absolute path"
|
|
202
|
+
if (
|
|
203
|
+
typeof value.marker !== "string" ||
|
|
204
|
+
value.marker.length === 0 ||
|
|
205
|
+
value.marker.length > MAX_SMOKE_MARKER_LENGTH
|
|
206
|
+
)
|
|
207
|
+
return "ssr.marker must be non-empty and bounded"
|
|
208
|
+
if (value.expectedStatus !== undefined && !validStatus(value.expectedStatus))
|
|
209
|
+
return "ssr.expectedStatus is invalid"
|
|
210
|
+
if (value.expectedHeaders !== undefined)
|
|
211
|
+
return headerError(value.expectedHeaders, "ssr.expectedHeaders")
|
|
212
|
+
return undefined
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function fixtureError(fixture: unknown, inProcess: boolean): string | undefined {
|
|
216
|
+
if (!isRecord(fixture)) return "fixture must export an object"
|
|
217
|
+
const app = fixture.app
|
|
218
|
+
if ((typeof app !== "object" && typeof app !== "function") || app === null)
|
|
219
|
+
return "fixture.app must be an object with fetch(request)"
|
|
220
|
+
if (typeof (app as { fetch?: unknown }).fetch !== "function")
|
|
221
|
+
return "fixture.app.fetch must be a function"
|
|
222
|
+
if (!inProcess && typeof fixture.start !== "function")
|
|
223
|
+
return "fixture.start must be a function in production mode"
|
|
224
|
+
if (inProcess && fixture.start !== undefined && typeof fixture.start !== "function")
|
|
225
|
+
return "fixture.start must be a function when provided"
|
|
226
|
+
if (fixture.ssr === undefined) return "SSR check is not configured"
|
|
227
|
+
const ssrError = smokeSsrError(fixture.ssr)
|
|
228
|
+
if (ssrError !== undefined) return ssrError
|
|
229
|
+
|
|
230
|
+
const mountedApi = fixture.mountedApi
|
|
231
|
+
if (!Array.isArray(mountedApi) || mountedApi.length === 0)
|
|
232
|
+
return "at least one mounted API witness is required"
|
|
233
|
+
for (let index = 0; index < mountedApi.length; index++) {
|
|
234
|
+
const error = requestCheckError(mountedApi[index], `mountedApi[${index}]`)
|
|
235
|
+
if (error !== undefined) return error
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const notFound = fixture.notFound ?? {
|
|
239
|
+
id: "not-found",
|
|
240
|
+
path: "/__nifra_smoke_missing__",
|
|
241
|
+
expectedStatus: 404,
|
|
242
|
+
}
|
|
243
|
+
const notFoundError = requestCheckError(notFound, "notFound")
|
|
244
|
+
if (notFoundError !== undefined) return notFoundError
|
|
245
|
+
if (!isRecord(notFound) || notFound.expectedStatus !== 404)
|
|
246
|
+
return "notFound.expectedStatus must be 404"
|
|
247
|
+
|
|
248
|
+
for (const [field, category] of [
|
|
249
|
+
["securityHeaders", "securityHeaders"],
|
|
250
|
+
["authCallbacks", "authCallbacks"],
|
|
251
|
+
] as const) {
|
|
252
|
+
const checks = fixture[field]
|
|
253
|
+
if (checks === undefined) continue
|
|
254
|
+
if (!Array.isArray(checks)) return `${category} must be an array`
|
|
255
|
+
for (let index = 0; index < checks.length; index++) {
|
|
256
|
+
const error = requestCheckError(checks[index], `${category}[${index}]`)
|
|
257
|
+
if (error !== undefined) return error
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (fixture.contract !== undefined && typeof fixture.contract !== "function")
|
|
261
|
+
return "contract must be a function"
|
|
262
|
+
if (fixture.hydration !== undefined && typeof fixture.hydration !== "function")
|
|
263
|
+
return "hydration must be a function"
|
|
264
|
+
return undefined
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const responseContentType = (response: Response): string =>
|
|
268
|
+
response.headers.get("content-type")?.split(";", 1)[0] ?? ""
|
|
269
|
+
|
|
270
|
+
const expectedHeaderMatches = (
|
|
271
|
+
response: Response,
|
|
272
|
+
expected: Readonly<Record<string, string>> | undefined,
|
|
273
|
+
): boolean =>
|
|
274
|
+
Object.entries(expected ?? {}).every(([name, value]) => response.headers.get(name) === value)
|
|
275
|
+
|
|
276
|
+
function responseLike(value: unknown): value is Response {
|
|
277
|
+
if (value === null || typeof value !== "object") return false
|
|
278
|
+
const candidate = value as {
|
|
279
|
+
readonly status?: unknown
|
|
280
|
+
readonly headers?: { readonly get?: unknown }
|
|
281
|
+
readonly text?: unknown
|
|
282
|
+
readonly body?: unknown
|
|
283
|
+
}
|
|
284
|
+
return (
|
|
285
|
+
typeof candidate.status === "number" &&
|
|
286
|
+
Number.isInteger(candidate.status) &&
|
|
287
|
+
candidate.status >= 100 &&
|
|
288
|
+
candidate.status <= 599 &&
|
|
289
|
+
typeof candidate.headers?.get === "function" &&
|
|
290
|
+
typeof candidate.text === "function" &&
|
|
291
|
+
(candidate.body === null || typeof candidate.body === "object")
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function boundedResponseText(
|
|
296
|
+
response: Response,
|
|
297
|
+
): Promise<{ readonly text?: string; readonly tooLarge: boolean }> {
|
|
298
|
+
if (response.body === null) return { text: "", tooLarge: false }
|
|
299
|
+
const reader = response.body.getReader()
|
|
300
|
+
const chunks: Uint8Array[] = []
|
|
301
|
+
let total = 0
|
|
302
|
+
try {
|
|
303
|
+
for (;;) {
|
|
304
|
+
const next = await reader.read()
|
|
305
|
+
if (next.done) break
|
|
306
|
+
total += next.value.byteLength
|
|
307
|
+
if (total > MAX_SMOKE_RESPONSE_BODY_LENGTH) {
|
|
308
|
+
try {
|
|
309
|
+
await reader.cancel()
|
|
310
|
+
} catch {
|
|
311
|
+
// The response is already a failed witness; cancellation errors are not actionable here.
|
|
312
|
+
}
|
|
313
|
+
return { tooLarge: true }
|
|
314
|
+
}
|
|
315
|
+
chunks.push(next.value)
|
|
316
|
+
}
|
|
317
|
+
} finally {
|
|
318
|
+
reader.releaseLock()
|
|
319
|
+
}
|
|
320
|
+
const bytes = new Uint8Array(total)
|
|
321
|
+
let offset = 0
|
|
322
|
+
for (const chunk of chunks) {
|
|
323
|
+
bytes.set(chunk, offset)
|
|
324
|
+
offset += chunk.byteLength
|
|
325
|
+
}
|
|
326
|
+
return { text: new TextDecoder().decode(bytes), tooLarge: false }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function requestFor(origin: string, check: SmokeRequestCheck): Request {
|
|
330
|
+
const method = check.method ?? "GET"
|
|
331
|
+
const headers = new Headers(check.headers)
|
|
332
|
+
return new Request(new URL(check.path, origin).href, {
|
|
333
|
+
method,
|
|
334
|
+
headers,
|
|
335
|
+
...(check.body === undefined ? {} : { body: check.body }),
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function validateRequestCheck(
|
|
340
|
+
check: SmokeRequestCheck,
|
|
341
|
+
response: Response,
|
|
342
|
+
): Promise<{ readonly ok: boolean; readonly message?: string }> {
|
|
343
|
+
return (async () => {
|
|
344
|
+
if (!responseLike(response)) return { ok: false, message: "fetch returned an invalid response" }
|
|
345
|
+
if (response.status !== check.expectedStatus) {
|
|
346
|
+
return { ok: false, message: `status ${response.status}, expected ${check.expectedStatus}` }
|
|
347
|
+
}
|
|
348
|
+
if (!expectedHeaderMatches(response, check.expectedHeaders)) {
|
|
349
|
+
return { ok: false, message: "expected header missing or mismatched" }
|
|
350
|
+
}
|
|
351
|
+
if (check.contentType !== undefined) {
|
|
352
|
+
const expected = check.contentType === "json" ? "application/json" : "text/plain"
|
|
353
|
+
if (responseContentType(response) !== expected) {
|
|
354
|
+
return { ok: false, message: `content type ${responseContentType(response) || "missing"}` }
|
|
355
|
+
}
|
|
356
|
+
if (check.contentType === "json") {
|
|
357
|
+
const body = await boundedResponseText(response)
|
|
358
|
+
if (body.tooLarge) return { ok: false, message: "response body is too large" }
|
|
359
|
+
try {
|
|
360
|
+
JSON.parse(body.text ?? "")
|
|
361
|
+
} catch {
|
|
362
|
+
return { ok: false, message: "response is not valid JSON" }
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return { ok: true }
|
|
367
|
+
})()
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Run the safe, bounded production smoke checks declared by one application fixture. */
|
|
371
|
+
export async function runSmoke(
|
|
372
|
+
fixture: SmokeFixture,
|
|
373
|
+
options: SmokeRunOptions = {},
|
|
374
|
+
): Promise<SmokeReport> {
|
|
375
|
+
const checks: SmokeCheckResult[] = []
|
|
376
|
+
const add = (
|
|
377
|
+
category: SmokeCheckResult["category"],
|
|
378
|
+
id: string,
|
|
379
|
+
status: SmokeCheckResult["status"],
|
|
380
|
+
message?: string,
|
|
381
|
+
): void => {
|
|
382
|
+
checks.push({
|
|
383
|
+
category,
|
|
384
|
+
id: safeId(id, `${category}-${checks.length + 1}`),
|
|
385
|
+
status,
|
|
386
|
+
...(message === undefined ? {} : { message }),
|
|
387
|
+
})
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const inProcess = options.inProcess === true
|
|
391
|
+
const invalidFixture = fixtureError(fixture, inProcess)
|
|
392
|
+
if (invalidFixture !== undefined) {
|
|
393
|
+
add("start", "fixture", "fail", invalidFixture)
|
|
394
|
+
return reportOf(checks, inProcess ? "in-process" : "production")
|
|
395
|
+
}
|
|
396
|
+
let server: SmokeServer | undefined
|
|
397
|
+
try {
|
|
398
|
+
if (inProcess) {
|
|
399
|
+
add("start", "in-process", "pass")
|
|
400
|
+
} else {
|
|
401
|
+
try {
|
|
402
|
+
server = await fixture.start!(fixture.app)
|
|
403
|
+
if (
|
|
404
|
+
server === null ||
|
|
405
|
+
typeof server.origin !== "string" ||
|
|
406
|
+
server.origin.trim() === "" ||
|
|
407
|
+
typeof server.stop !== "function" ||
|
|
408
|
+
(server.fetch !== undefined && typeof server.fetch !== "function")
|
|
409
|
+
) {
|
|
410
|
+
add("start", "production-start", "fail", "start function returned an invalid server")
|
|
411
|
+
return reportOf(checks, "production")
|
|
412
|
+
}
|
|
413
|
+
const originUrl = new URL(server.origin)
|
|
414
|
+
if (
|
|
415
|
+
(originUrl.protocol !== "http:" && originUrl.protocol !== "https:") ||
|
|
416
|
+
originUrl.username !== "" ||
|
|
417
|
+
originUrl.password !== "" ||
|
|
418
|
+
originUrl.pathname !== "/" ||
|
|
419
|
+
originUrl.search !== "" ||
|
|
420
|
+
originUrl.hash !== ""
|
|
421
|
+
) {
|
|
422
|
+
add("start", "production-start", "fail", "production server origin is invalid")
|
|
423
|
+
return reportOf(checks, "production")
|
|
424
|
+
}
|
|
425
|
+
add("start", "production-start", "pass")
|
|
426
|
+
} catch {
|
|
427
|
+
add("start", "production-start", "fail", "production server failed to start")
|
|
428
|
+
return reportOf(checks, "production")
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const origin = server?.origin ?? "http://nifra-smoke.invalid"
|
|
433
|
+
const fetchRequest = async (check: SmokeRequestCheck): Promise<Response> => {
|
|
434
|
+
const request = requestFor(origin, check)
|
|
435
|
+
return server?.fetch !== undefined
|
|
436
|
+
? await server.fetch(request)
|
|
437
|
+
: inProcess
|
|
438
|
+
? await fixture.app.fetch(request)
|
|
439
|
+
: await globalThis.fetch(request)
|
|
440
|
+
}
|
|
441
|
+
const hookContext: SmokeHookContext = { app: fixture.app, origin, request: fetchRequest }
|
|
442
|
+
|
|
443
|
+
if (fixture.ssr === undefined) {
|
|
444
|
+
add("ssr", "ssr", "fail", "SSR check is not configured")
|
|
445
|
+
} else {
|
|
446
|
+
try {
|
|
447
|
+
const response = await fetchRequest({
|
|
448
|
+
id: "ssr",
|
|
449
|
+
path: fixture.ssr.path,
|
|
450
|
+
expectedStatus: fixture.ssr.expectedStatus ?? 200,
|
|
451
|
+
...(fixture.ssr.expectedHeaders === undefined
|
|
452
|
+
? {}
|
|
453
|
+
: { expectedHeaders: fixture.ssr.expectedHeaders }),
|
|
454
|
+
})
|
|
455
|
+
if (!responseLike(response)) {
|
|
456
|
+
add("ssr", "ssr", "fail", "fetch returned an invalid response")
|
|
457
|
+
} else if (response.status !== (fixture.ssr.expectedStatus ?? 200)) {
|
|
458
|
+
add("ssr", "ssr", "fail", `status ${response.status}`)
|
|
459
|
+
} else if (responseContentType(response) !== "text/html") {
|
|
460
|
+
add("ssr", "ssr", "fail", "response is not HTML")
|
|
461
|
+
} else {
|
|
462
|
+
const body = await boundedResponseText(response)
|
|
463
|
+
if (body.tooLarge) add("ssr", "ssr", "fail", "response body is too large")
|
|
464
|
+
else if (!(body.text ?? "").includes(fixture.ssr.marker))
|
|
465
|
+
add("ssr", "ssr", "fail", "SSR marker is missing")
|
|
466
|
+
else if (!expectedHeaderMatches(response, fixture.ssr.expectedHeaders))
|
|
467
|
+
add("ssr", "ssr", "fail", "expected header missing or mismatched")
|
|
468
|
+
else add("ssr", "ssr", "pass")
|
|
469
|
+
}
|
|
470
|
+
} catch {
|
|
471
|
+
add("ssr", "ssr", "fail", "SSR request failed")
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const mountedApi = fixture.mountedApi
|
|
476
|
+
if (mountedApi === undefined || mountedApi.length === 0) {
|
|
477
|
+
add("api", "mounted-api", "fail", "at least one mounted API witness is required")
|
|
478
|
+
} else {
|
|
479
|
+
for (const check of mountedApi) {
|
|
480
|
+
try {
|
|
481
|
+
const result = await validateRequestCheck(check, await fetchRequest(check))
|
|
482
|
+
add("api", check.id, result.ok ? "pass" : "fail", result.message)
|
|
483
|
+
} catch {
|
|
484
|
+
add("api", check.id, "fail", "mounted API request failed")
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const notFound = fixture.notFound ?? {
|
|
490
|
+
id: "not-found",
|
|
491
|
+
path: "/__nifra_smoke_missing__",
|
|
492
|
+
expectedStatus: 404,
|
|
493
|
+
}
|
|
494
|
+
try {
|
|
495
|
+
const result = await validateRequestCheck(notFound, await fetchRequest(notFound))
|
|
496
|
+
add("not-found", notFound.id, result.ok ? "pass" : "fail", result.message)
|
|
497
|
+
} catch {
|
|
498
|
+
add("not-found", notFound.id, "fail", "404 request failed")
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
for (const check of fixture.securityHeaders ?? []) {
|
|
502
|
+
try {
|
|
503
|
+
const result = await validateRequestCheck(check, await fetchRequest(check))
|
|
504
|
+
add("headers", check.id, result.ok ? "pass" : "fail", result.message)
|
|
505
|
+
} catch {
|
|
506
|
+
add("headers", check.id, "fail", "security-header request failed")
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (fixture.securityHeaders === undefined) add("headers", "security-headers", "skip")
|
|
510
|
+
|
|
511
|
+
for (const check of fixture.authCallbacks ?? []) {
|
|
512
|
+
try {
|
|
513
|
+
const result = await validateRequestCheck(check, await fetchRequest(check))
|
|
514
|
+
add("auth", check.id, result.ok ? "pass" : "fail", result.message)
|
|
515
|
+
} catch {
|
|
516
|
+
add("auth", check.id, "fail", "auth callback request failed")
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (fixture.authCallbacks === undefined) add("auth", "auth-callbacks", "skip")
|
|
520
|
+
|
|
521
|
+
if (fixture.contract === undefined) {
|
|
522
|
+
add("contract", "response-contract", "skip")
|
|
523
|
+
} else {
|
|
524
|
+
try {
|
|
525
|
+
const result = await fixture.contract(hookContext)
|
|
526
|
+
if (
|
|
527
|
+
result === undefined ||
|
|
528
|
+
typeof result !== "object" ||
|
|
529
|
+
result === null ||
|
|
530
|
+
typeof result.ok !== "boolean" ||
|
|
531
|
+
(result.failures !== undefined &&
|
|
532
|
+
(!Number.isInteger(result.failures) || result.failures < 0)) ||
|
|
533
|
+
(result.gaps !== undefined && (!Number.isInteger(result.gaps) || result.gaps < 0))
|
|
534
|
+
) {
|
|
535
|
+
add("contract", "response-contract", "fail", "contract hook returned no verdict")
|
|
536
|
+
} else if (!result.ok || (result.failures ?? 0) > 0 || (result.gaps ?? 0) > 0) {
|
|
537
|
+
add("contract", "response-contract", "fail", "contract witnesses failed")
|
|
538
|
+
} else {
|
|
539
|
+
add("contract", "response-contract", "pass")
|
|
540
|
+
}
|
|
541
|
+
} catch {
|
|
542
|
+
add("contract", "response-contract", "fail", "contract hook failed")
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (fixture.hydration === undefined) {
|
|
547
|
+
add("hydration", "hydration", "skip")
|
|
548
|
+
} else {
|
|
549
|
+
try {
|
|
550
|
+
await fixture.hydration(hookContext)
|
|
551
|
+
add("hydration", "hydration", "pass")
|
|
552
|
+
} catch {
|
|
553
|
+
add("hydration", "hydration", "fail", "hydration hook failed")
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
} finally {
|
|
557
|
+
if (server !== undefined) {
|
|
558
|
+
try {
|
|
559
|
+
await server.stop()
|
|
560
|
+
} catch {
|
|
561
|
+
add("start", "production-stop", "fail", "production server failed to stop")
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return reportOf(checks, inProcess ? "in-process" : "production")
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function reportOf(checks: readonly SmokeCheckResult[], mode: SmokeReport["mode"]): SmokeReport {
|
|
569
|
+
const passed = checks.filter((check) => check.status === "pass").length
|
|
570
|
+
const failed = checks.filter((check) => check.status === "fail").length
|
|
571
|
+
const skipped = checks.length - passed - failed
|
|
572
|
+
return {
|
|
573
|
+
version: 1,
|
|
574
|
+
ok: failed === 0,
|
|
575
|
+
mode,
|
|
576
|
+
checks: Object.freeze([...checks]),
|
|
577
|
+
counts: { passed, failed, skipped },
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** Load a user-owned smoke fixture without executing arbitrary project code during catalog/help. */
|
|
582
|
+
export async function loadSmokeFixture(
|
|
583
|
+
cwd: string,
|
|
584
|
+
fixturePath = "nifra.smoke.ts",
|
|
585
|
+
): Promise<SmokeFixture> {
|
|
586
|
+
if (typeof fixturePath !== "string" || fixturePath.length === 0 || fixturePath.length > 256)
|
|
587
|
+
throw new Error("smoke fixture path must be a non-empty bounded project-relative path")
|
|
588
|
+
const path = resolve(cwd, fixturePath)
|
|
589
|
+
const projectRoot = await realpath(cwd).catch(() => undefined)
|
|
590
|
+
const fixtureRoot = await realpath(path).catch(() => undefined)
|
|
591
|
+
if (projectRoot === undefined || fixtureRoot === undefined)
|
|
592
|
+
throw new Error("smoke fixture not found")
|
|
593
|
+
const relativeFixture = relative(projectRoot, fixtureRoot)
|
|
594
|
+
if (relativeFixture === "" || relativeFixture.startsWith("..") || isAbsolute(relativeFixture))
|
|
595
|
+
throw new Error("smoke fixture must be inside the project")
|
|
596
|
+
const fixtureStats = await stat(fixtureRoot).catch(() => undefined)
|
|
597
|
+
if (fixtureStats === undefined || !fixtureStats.isFile())
|
|
598
|
+
throw new Error("smoke fixture must resolve to a file")
|
|
599
|
+
const specifier = pathToFileURL(fixtureRoot)
|
|
600
|
+
specifier.searchParams.set("nifra-smoke", String(Date.now()))
|
|
601
|
+
const loaded = (await import(specifier.href)) as { default?: unknown }
|
|
602
|
+
const fixture = loaded.default
|
|
603
|
+
if (fixture === null || typeof fixture !== "object") {
|
|
604
|
+
throw new Error("smoke fixture must default-export an object")
|
|
605
|
+
}
|
|
606
|
+
return fixture as SmokeFixture
|
|
607
|
+
}
|
package/src/sync-manifest.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { join } from "node:path"
|
|
14
14
|
import { Glob } from "bun"
|
|
15
|
+
import { codeUnitOrder } from "./internal/code-unit-order.ts"
|
|
15
16
|
|
|
16
17
|
// The marker `generateServerManifest` writes at the top of the file - so a user file merely named
|
|
17
18
|
// `server-manifest.ts` is never mistaken for a generated one. Mirrors `nifra check`'s drift scan.
|
|
@@ -77,7 +78,7 @@ export async function syncServerManifests(cwd: string): Promise<SyncManifestResu
|
|
|
77
78
|
const drift = diffManifestRoutes(before, after)
|
|
78
79
|
results.push({ file: rel, changed, added: drift.missing, removed: drift.extra })
|
|
79
80
|
}
|
|
80
|
-
return results.sort((a, b) => a.file
|
|
81
|
+
return results.sort((a, b) => codeUnitOrder(a.file, b.file))
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
/** CLI entry: sync every generated manifest, print a per-file summary + the client-rebuild caveat. */
|
package/src/sync-routes.ts
CHANGED
|
@@ -24,9 +24,10 @@ export interface SyncRoutesResult {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* Generate the route-search types `.d.ts` from `<cwd>/routes` (the nifra convention). Returns
|
|
28
|
-
* there is no `routes/` directory (an API-only project - nothing to type). Never loads
|
|
29
|
-
* needs only the route tree, so it works before a build and on a broken
|
|
27
|
+
* Generate the route-search types `.d.ts` from `<cwd>/routes` (the nifra convention). Returns
|
|
28
|
+
* `null` when there is no `routes/` directory (an API-only project - nothing to type). Never loads
|
|
29
|
+
* `backend/framework.ts`: it needs only the route tree, so it works before a build and on a broken
|
|
30
|
+
* framework config.
|
|
30
31
|
*/
|
|
31
32
|
export async function syncRouteTypes(cwd: string): Promise<SyncRoutesResult | null> {
|
|
32
33
|
const routesDir = join(cwd, "routes")
|