@nifrajs/cli 2.10.0 → 2.12.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 +7 -1
- package/dist/assure-hydration.d.ts +14 -0
- package/dist/assure-hydration.d.ts.map +1 -0
- package/dist/assure-hydration.js +483 -0
- package/dist/assure-hydration.js.map +1 -0
- package/dist/assure.d.ts +35 -0
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +222 -1
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts +13 -0
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +64 -1
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check.d.ts +133 -10
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +741 -148
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +21 -14
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +288 -73
- package/dist/cli.js.map +1 -1
- package/dist/contract-proof.d.ts +49 -0
- package/dist/contract-proof.d.ts.map +1 -0
- package/dist/contract-proof.js +128 -0
- package/dist/contract-proof.js.map +1 -0
- package/dist/contracts.d.ts +22 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +134 -0
- package/dist/contracts.js.map +1 -0
- package/dist/dev-bun-config.d.ts +17 -6
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +51 -14
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/diagnostics.d.ts +23 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +9 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/diff-tool.d.ts +2 -0
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +1 -1
- package/dist/diff-tool.js.map +1 -1
- package/dist/docs-search.d.ts +8 -3
- package/dist/docs-search.d.ts.map +1 -1
- package/dist/docs-search.js +36 -15
- package/dist/docs-search.js.map +1 -1
- package/dist/doctor.d.ts +38 -2
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +189 -7
- package/dist/doctor.js.map +1 -1
- package/dist/examples.d.ts +1 -2
- package/dist/examples.d.ts.map +1 -1
- package/dist/examples.js +37 -16
- package/dist/examples.js.map +1 -1
- package/dist/fix-recipes.d.ts +12 -0
- package/dist/fix-recipes.d.ts.map +1 -0
- package/dist/fix-recipes.js +106 -0
- package/dist/fix-recipes.js.map +1 -0
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +60 -4
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/source-facts.d.ts +15 -0
- package/dist/internal/source-facts.d.ts.map +1 -0
- package/dist/internal/source-facts.js +82 -0
- package/dist/internal/source-facts.js.map +1 -0
- package/dist/internal/typescript-import.d.ts +9 -0
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +38 -0
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +6 -2
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +60 -5
- package/dist/introspect.js.map +1 -1
- package/dist/load.d.ts +10 -0
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +1 -0
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +14 -9
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +3 -0
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-root.d.ts +83 -0
- package/dist/mcp-root.d.ts.map +1 -0
- package/dist/mcp-root.js +221 -0
- package/dist/mcp-root.js.map +1 -0
- package/dist/mcp-run.d.ts.map +1 -1
- package/dist/mcp-run.js +18 -4
- package/dist/mcp-run.js.map +1 -1
- package/dist/mcp.d.ts +27 -4
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +360 -27
- package/dist/mcp.js.map +1 -1
- package/dist/pipeline-guard.d.ts +25 -10
- package/dist/pipeline-guard.d.ts.map +1 -1
- package/dist/pipeline-guard.js +35 -15
- package/dist/pipeline-guard.js.map +1 -1
- package/dist/pipeline-report.d.ts +34 -0
- package/dist/pipeline-report.d.ts.map +1 -0
- package/dist/pipeline-report.js +282 -0
- package/dist/pipeline-report.js.map +1 -0
- package/dist/project-path.d.ts +3 -0
- package/dist/project-path.d.ts.map +1 -0
- package/dist/project-path.js +36 -0
- package/dist/project-path.js.map +1 -0
- package/dist/replay.d.ts +12 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +36 -0
- package/dist/replay.js.map +1 -0
- package/dist/rules/codes.d.ts +42 -0
- package/dist/rules/codes.d.ts.map +1 -0
- package/dist/rules/codes.js +52 -0
- package/dist/rules/codes.js.map +1 -0
- package/dist/rules/comment-markers.d.ts +10 -0
- package/dist/rules/comment-markers.d.ts.map +1 -0
- package/dist/rules/comment-markers.js +24 -0
- package/dist/rules/comment-markers.js.map +1 -0
- package/dist/rules/index.d.ts +31 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/index.js +133 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/rules/legacy.d.ts +7 -0
- package/dist/rules/legacy.d.ts.map +1 -0
- package/dist/rules/legacy.js +85 -0
- package/dist/rules/legacy.js.map +1 -0
- package/dist/rules/routes.d.ts +12 -0
- package/dist/rules/routes.d.ts.map +1 -0
- package/dist/rules/routes.js +142 -0
- package/dist/rules/routes.js.map +1 -0
- package/dist/rules/security.d.ts +10 -0
- package/dist/rules/security.d.ts.map +1 -0
- package/dist/rules/security.js +471 -0
- package/dist/rules/security.js.map +1 -0
- package/dist/search-terms.d.ts +2 -0
- package/dist/search-terms.d.ts.map +1 -1
- package/dist/search-terms.js +5 -1
- package/dist/search-terms.js.map +1 -1
- package/dist/types-search.d.ts +1 -2
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +33 -16
- package/dist/types-search.js.map +1 -1
- package/dist/verification.d.ts +9 -0
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +17 -1
- package/dist/verification.js.map +1 -1
- package/dist/work-graph.d.ts +123 -0
- package/dist/work-graph.d.ts.map +1 -0
- package/dist/work-graph.js +465 -0
- package/dist/work-graph.js.map +1 -0
- package/docs/examples.json +32 -2
- package/docs/llms-full.txt +1105 -27
- package/docs/types.json +1772 -137
- package/package.json +34 -9
- package/src/assure-hydration.ts +606 -0
- package/src/assure.ts +316 -2
- package/src/capabilities-tool.ts +80 -1
- package/src/check.ts +923 -169
- package/src/cli.ts +297 -76
- package/src/contract-proof.ts +194 -0
- package/src/contracts.ts +157 -0
- package/src/dev-bun-config.ts +57 -14
- package/src/diagnostics.ts +32 -0
- package/src/diff-tool.ts +1 -1
- package/src/docs-search.ts +45 -15
- package/src/doctor.ts +286 -8
- package/src/examples.ts +43 -15
- package/src/fix-recipes.ts +126 -0
- package/src/init-agents.ts +61 -4
- package/src/internal/source-facts.ts +133 -0
- package/src/internal/typescript-import.ts +29 -0
- package/src/introspect.ts +81 -6
- package/src/load.ts +11 -0
- package/src/manifest-tool.ts +16 -10
- package/src/mcp-docs-tools.ts +3 -0
- package/src/mcp-http.ts +1 -1
- package/src/mcp-root.ts +271 -0
- package/src/mcp-run.ts +19 -4
- package/src/mcp.ts +440 -34
- package/src/pipeline-guard.ts +44 -14
- package/src/pipeline-report.ts +341 -0
- package/src/project-path.ts +36 -0
- package/src/replay.ts +44 -0
- package/src/rules/codes.ts +57 -0
- package/src/rules/comment-markers.ts +26 -0
- package/src/rules/index.ts +175 -0
- package/src/rules/legacy.ts +93 -0
- package/src/rules/routes.ts +157 -0
- package/src/rules/security.ts +505 -0
- package/src/search-terms.ts +9 -4
- package/src/types-search.ts +39 -15
- package/src/verification.ts +18 -1
- package/src/work-graph.ts +613 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.0",
|
|
4
4
|
"description": "The nifra CLI - zero-config dev/build/start for a nifra app (`nifra dev`, `nifra build`, `nifra start`).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -25,6 +25,31 @@
|
|
|
25
25
|
"bun": "./src/mcp-http.ts",
|
|
26
26
|
"types": "./dist/mcp-http.d.ts",
|
|
27
27
|
"default": "./dist/mcp-http.js"
|
|
28
|
+
},
|
|
29
|
+
"./diagnostics": {
|
|
30
|
+
"bun": "./src/diagnostics.ts",
|
|
31
|
+
"types": "./dist/diagnostics.d.ts",
|
|
32
|
+
"default": "./dist/diagnostics.js"
|
|
33
|
+
},
|
|
34
|
+
"./rules": {
|
|
35
|
+
"bun": "./src/rules/index.ts",
|
|
36
|
+
"types": "./dist/rules/index.d.ts",
|
|
37
|
+
"default": "./dist/rules/index.js"
|
|
38
|
+
},
|
|
39
|
+
"./fix-recipes": {
|
|
40
|
+
"bun": "./src/fix-recipes.ts",
|
|
41
|
+
"types": "./dist/fix-recipes.d.ts",
|
|
42
|
+
"default": "./dist/fix-recipes.js"
|
|
43
|
+
},
|
|
44
|
+
"./assure": {
|
|
45
|
+
"bun": "./src/assure.ts",
|
|
46
|
+
"types": "./dist/assure.d.ts",
|
|
47
|
+
"default": "./dist/assure.js"
|
|
48
|
+
},
|
|
49
|
+
"./work-graph": {
|
|
50
|
+
"bun": "./src/work-graph.ts",
|
|
51
|
+
"types": "./dist/work-graph.d.ts",
|
|
52
|
+
"default": "./dist/work-graph.js"
|
|
28
53
|
}
|
|
29
54
|
},
|
|
30
55
|
"files": [
|
|
@@ -41,14 +66,14 @@
|
|
|
41
66
|
"access": "public"
|
|
42
67
|
},
|
|
43
68
|
"dependencies": {
|
|
44
|
-
"@nifrajs/client": "2.
|
|
45
|
-
"@nifrajs/core": "2.
|
|
46
|
-
"@nifrajs/mcp": "2.
|
|
47
|
-
"@nifrajs/runner": "2.
|
|
48
|
-
"@nifrajs/schema": "2.
|
|
49
|
-
"@nifrajs/testing": "2.
|
|
50
|
-
"@nifrajs/web": "2.
|
|
51
|
-
"create-nifra": "2.
|
|
69
|
+
"@nifrajs/client": "2.12.0",
|
|
70
|
+
"@nifrajs/core": "2.12.0",
|
|
71
|
+
"@nifrajs/mcp": "2.12.0",
|
|
72
|
+
"@nifrajs/runner": "2.12.0",
|
|
73
|
+
"@nifrajs/schema": "2.12.0",
|
|
74
|
+
"@nifrajs/testing": "2.12.0",
|
|
75
|
+
"@nifrajs/web": "2.12.0",
|
|
76
|
+
"create-nifra": "2.12.0"
|
|
52
77
|
},
|
|
53
78
|
"repository": {
|
|
54
79
|
"type": "git",
|
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs"
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { basename, join, resolve } from "node:path"
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
6
|
+
import { inProcessClient } from "@nifrajs/client"
|
|
7
|
+
import { defineReplayFile } from "@nifrajs/core/replay"
|
|
8
|
+
import { type AppLike, runApp } from "@nifrajs/runner"
|
|
9
|
+
import { createWebApp, type RenderAdapter } from "@nifrajs/web"
|
|
10
|
+
import { type BuildManifest, buildClient } from "@nifrajs/web/build"
|
|
11
|
+
import { discoverRoutes } from "@nifrajs/web/fs"
|
|
12
|
+
import type { BunPlugin } from "bun"
|
|
13
|
+
import { collectDoctorResult } from "./doctor.ts"
|
|
14
|
+
import { loadApp, resolvePlugins } from "./load.ts"
|
|
15
|
+
|
|
16
|
+
const HYDRATION_ASSURANCE = Symbol.for("nifra.hydration.assurance")
|
|
17
|
+
const SOURCE_GLOB = "**/*.{ts,tsx,js,jsx,vue,svelte,mdx}"
|
|
18
|
+
const IGNORED = /(^|\/)(node_modules|dist|build|coverage|\.git|\.nifra)\//
|
|
19
|
+
|
|
20
|
+
export interface HydrationResult {
|
|
21
|
+
readonly diagnostics: readonly import("./diagnostics.ts").Diagnostic[]
|
|
22
|
+
readonly skipReason?: string
|
|
23
|
+
/** Relative replay files, one for every route that produced a failure. */
|
|
24
|
+
readonly replays?: readonly string[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface HydrationOptions {
|
|
28
|
+
readonly routes?: readonly string[]
|
|
29
|
+
readonly interact?: boolean
|
|
30
|
+
readonly seed?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function seedNumber(seed: string): number {
|
|
34
|
+
let value = 0x811c9dc5
|
|
35
|
+
for (const character of seed) {
|
|
36
|
+
value ^= character.charCodeAt(0)
|
|
37
|
+
value = Math.imul(value, 0x01000193)
|
|
38
|
+
}
|
|
39
|
+
return value >>> 0
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Freeze the small nondeterministic surface that can make SSR and CSR disagree by luck. */
|
|
43
|
+
function installDeterministicRuntime(seed: string): void {
|
|
44
|
+
let randomState = seedNumber(seed) || 1
|
|
45
|
+
const epoch = 1_700_000_000_000 + (seedNumber(`${seed}:clock`) % 86_400_000)
|
|
46
|
+
Date.now = () => epoch
|
|
47
|
+
Math.random = () => {
|
|
48
|
+
randomState = Math.imul(randomState, 1_664_525) + 1_013_904_223
|
|
49
|
+
return (randomState >>> 0) / 4_294_967_296
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const realSetTimeout = globalThis.setTimeout.bind(globalThis)
|
|
53
|
+
const realSetInterval = globalThis.setInterval.bind(globalThis)
|
|
54
|
+
globalThis.setTimeout = ((
|
|
55
|
+
handler: Parameters<typeof setTimeout>[0],
|
|
56
|
+
timeout?: number,
|
|
57
|
+
...args: unknown[]
|
|
58
|
+
) =>
|
|
59
|
+
realSetTimeout(
|
|
60
|
+
handler,
|
|
61
|
+
Number.isFinite(timeout) ? Math.max(0, timeout ?? 0) : 0,
|
|
62
|
+
...args,
|
|
63
|
+
)) as typeof setTimeout
|
|
64
|
+
globalThis.setInterval = ((
|
|
65
|
+
handler: Parameters<typeof setInterval>[0],
|
|
66
|
+
timeout?: number,
|
|
67
|
+
...args: unknown[]
|
|
68
|
+
) =>
|
|
69
|
+
realSetInterval(
|
|
70
|
+
handler,
|
|
71
|
+
Number.isFinite(timeout) ? Math.max(0, timeout ?? 0) : 0,
|
|
72
|
+
...args,
|
|
73
|
+
)) as typeof setInterval
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface BuiltHydrationApp {
|
|
77
|
+
readonly app: AppLike
|
|
78
|
+
readonly client: BuildManifest
|
|
79
|
+
readonly outputDir: string
|
|
80
|
+
readonly framework: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function diagnostic(
|
|
84
|
+
code: "NF-H001" | "NF-H002" | "NF-H003" | "NF-H004",
|
|
85
|
+
message: string,
|
|
86
|
+
file?: string,
|
|
87
|
+
evidence?: readonly string[],
|
|
88
|
+
severity: "error" | "info" = "error",
|
|
89
|
+
): import("./diagnostics.ts").Diagnostic {
|
|
90
|
+
return Object.freeze({
|
|
91
|
+
code,
|
|
92
|
+
severity,
|
|
93
|
+
message,
|
|
94
|
+
...(file === undefined ? {} : { file }),
|
|
95
|
+
...(evidence === undefined ? {} : { evidence }),
|
|
96
|
+
verify: "nifra assure --hydration --strict --json",
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function frameworkName(clientModule: string): string {
|
|
101
|
+
if (clientModule.includes("web-react")) return "react"
|
|
102
|
+
if (clientModule.includes("web-vue")) return "vue"
|
|
103
|
+
if (clientModule.includes("web-svelte")) return "svelte"
|
|
104
|
+
if (clientModule.includes("web-solid")) return "solid"
|
|
105
|
+
if (clientModule.includes("web-preact")) return "preact"
|
|
106
|
+
return "custom"
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function routePath(pattern: string): string {
|
|
110
|
+
const path = pattern
|
|
111
|
+
.replace(/\[\.\.\.([^\]]+)\]/g, "probe")
|
|
112
|
+
.replace(/\[([^\]]+)\]/g, "probe")
|
|
113
|
+
.replace(/:([A-Za-z0-9_]+)/g, "probe")
|
|
114
|
+
.replace(/\*([A-Za-z0-9_]+)?/g, "probe")
|
|
115
|
+
return path === "" ? "/" : path.startsWith("/") ? path : `/${path}`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function digest(value: unknown): Promise<string> {
|
|
119
|
+
const bytes = new TextEncoder().encode(JSON.stringify(value))
|
|
120
|
+
const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))
|
|
121
|
+
return [...hash].map((item) => item.toString(16).padStart(2, "0")).join("")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function writeReplay(
|
|
125
|
+
cwd: string,
|
|
126
|
+
route: string,
|
|
127
|
+
seed: string,
|
|
128
|
+
inputs: unknown,
|
|
129
|
+
): Promise<string> {
|
|
130
|
+
const inputsDigest = await digest(inputs)
|
|
131
|
+
const replay = defineReplayFile({
|
|
132
|
+
gate: "hydration",
|
|
133
|
+
case: route,
|
|
134
|
+
seed,
|
|
135
|
+
inputsDigest,
|
|
136
|
+
meta:
|
|
137
|
+
inputs !== null &&
|
|
138
|
+
typeof inputs === "object" &&
|
|
139
|
+
typeof (inputs as { framework?: unknown }).framework === "string"
|
|
140
|
+
? { framework: (inputs as { framework: string }).framework }
|
|
141
|
+
: {},
|
|
142
|
+
})
|
|
143
|
+
const relative = `.nifra/replays/hydration-${inputsDigest.slice(0, 16)}.json`
|
|
144
|
+
const path = resolve(cwd, relative)
|
|
145
|
+
await mkdir(join(cwd, ".nifra", "replays"), { recursive: true })
|
|
146
|
+
await writeFile(path, `${JSON.stringify(replay, null, 2)}\n`, "utf8")
|
|
147
|
+
return relative
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function loadOptionalDom(cwd: string): Promise<
|
|
151
|
+
| {
|
|
152
|
+
readonly Window: new (options?: { url?: string }) => unknown
|
|
153
|
+
}
|
|
154
|
+
| undefined
|
|
155
|
+
> {
|
|
156
|
+
try {
|
|
157
|
+
const entry = Bun.resolveSync("happy-dom", cwd)
|
|
158
|
+
const loaded = (await import(pathToFileURL(entry).href)) as {
|
|
159
|
+
readonly Window?: new (options?: { url?: string }) => unknown
|
|
160
|
+
}
|
|
161
|
+
return loaded.Window === undefined ? undefined : { Window: loaded.Window }
|
|
162
|
+
} catch {
|
|
163
|
+
return undefined
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function buildHydrationApp(cwd: string): Promise<BuiltHydrationApp | { skipReason: string }> {
|
|
168
|
+
const loaded = await loadApp(cwd, "dist")
|
|
169
|
+
if (loaded.framework.clientModule.trim() === "")
|
|
170
|
+
return { skipReason: "no client bundle configured" }
|
|
171
|
+
|
|
172
|
+
const workDir = await mkdtemp(join(tmpdir(), "nifra-hydration-"))
|
|
173
|
+
try {
|
|
174
|
+
const clientOutput = join(workDir, "assets")
|
|
175
|
+
const plugins = await resolvePlugins(loaded.framework.clientPlugins)
|
|
176
|
+
const client = await buildClient({
|
|
177
|
+
routesDir: loaded.routesDir,
|
|
178
|
+
outDir: clientOutput,
|
|
179
|
+
clientModule: loaded.framework.clientModule,
|
|
180
|
+
...(plugins.length === 0 ? {} : { plugins: plugins as BunPlugin[] }),
|
|
181
|
+
...(loaded.framework.conditions === undefined
|
|
182
|
+
? {}
|
|
183
|
+
: { conditions: loaded.framework.conditions }),
|
|
184
|
+
...(loaded.framework.define === undefined ? {} : { define: loaded.framework.define }),
|
|
185
|
+
publicDir: false,
|
|
186
|
+
...(loaded.framework.publicEnvPrefix === undefined
|
|
187
|
+
? {}
|
|
188
|
+
: { publicEnvPrefix: loaded.framework.publicEnvPrefix }),
|
|
189
|
+
})
|
|
190
|
+
if (client.entry.trim() === "" || client.assets.length === 0) {
|
|
191
|
+
await rm(workDir, { recursive: true, force: true })
|
|
192
|
+
return { skipReason: "client build emitted no browser assets" }
|
|
193
|
+
}
|
|
194
|
+
const serverPlugins = await resolvePlugins(loaded.framework.serverPlugins)
|
|
195
|
+
if (serverPlugins.length > 0) {
|
|
196
|
+
const { plugin } = await import("bun")
|
|
197
|
+
for (const item of serverPlugins) plugin(item as BunPlugin)
|
|
198
|
+
}
|
|
199
|
+
const webApp = createWebApp({
|
|
200
|
+
adapter: loaded.framework.adapter as RenderAdapter,
|
|
201
|
+
manifest: discoverRoutes(loaded.routesDir),
|
|
202
|
+
clientEntry: client.entry,
|
|
203
|
+
...(loaded.backend === undefined ? {} : { api: inProcessClient(loaded.backend as never) }),
|
|
204
|
+
}) as unknown as AppLike
|
|
205
|
+
return {
|
|
206
|
+
app: webApp,
|
|
207
|
+
client,
|
|
208
|
+
outputDir: clientOutput,
|
|
209
|
+
framework: frameworkName(loaded.framework.clientModule),
|
|
210
|
+
}
|
|
211
|
+
} catch (error) {
|
|
212
|
+
await rm(workDir, { recursive: true, force: true })
|
|
213
|
+
throw error
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function sourceBranch(content: string): boolean {
|
|
218
|
+
return /typeof\s+window\s*!==?\s*["']undefined["']/.test(content)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function sourceFiles(cwd: string): Promise<Array<{ file: string; content: string }>> {
|
|
222
|
+
const files: Array<{ file: string; content: string }> = []
|
|
223
|
+
for await (const file of new Bun.Glob(SOURCE_GLOB).scan({ cwd, onlyFiles: true, dot: false })) {
|
|
224
|
+
if (IGNORED.test(file)) continue
|
|
225
|
+
files.push({ file, content: await readFile(join(cwd, file), "utf8") })
|
|
226
|
+
}
|
|
227
|
+
return files
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function outputPath(outputDir: string, entry: string): string {
|
|
231
|
+
return join(outputDir, basename(entry))
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function globalValue(html: string, name: string): unknown {
|
|
235
|
+
const expression = new RegExp(`window\\.${name}=([\\s\\S]*?)(?:</script>|;window\\.)`)
|
|
236
|
+
const value = expression.exec(html)?.[1]?.replace(/;$/, "")
|
|
237
|
+
if (value === undefined) return undefined
|
|
238
|
+
try {
|
|
239
|
+
return JSON.parse(value)
|
|
240
|
+
} catch {
|
|
241
|
+
return undefined
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function runDomHydration(
|
|
246
|
+
cwd: string,
|
|
247
|
+
built: BuiltHydrationApp,
|
|
248
|
+
html: string,
|
|
249
|
+
path: string,
|
|
250
|
+
interact: boolean,
|
|
251
|
+
): Promise<import("./diagnostics.ts").Diagnostic[]> {
|
|
252
|
+
const domModule = await loadOptionalDom(cwd)
|
|
253
|
+
if (domModule === undefined) return []
|
|
254
|
+
const windowValue = new domModule.Window({ url: `http://nifra.test${path}` }) as Record<
|
|
255
|
+
string,
|
|
256
|
+
unknown
|
|
257
|
+
>
|
|
258
|
+
const documentValue = windowValue.document as Record<string, unknown>
|
|
259
|
+
const globals = globalThis as unknown as Record<string, unknown>
|
|
260
|
+
const globalRecord = globalThis as unknown as Record<PropertyKey, unknown>
|
|
261
|
+
const runtimeKey = Symbol.for("nifra.hydration.runtime")
|
|
262
|
+
const previous = new Map<string, unknown>()
|
|
263
|
+
const names = [
|
|
264
|
+
"window",
|
|
265
|
+
"document",
|
|
266
|
+
"navigator",
|
|
267
|
+
"location",
|
|
268
|
+
"history",
|
|
269
|
+
"HTMLElement",
|
|
270
|
+
"Element",
|
|
271
|
+
"Node",
|
|
272
|
+
"Event",
|
|
273
|
+
"MouseEvent",
|
|
274
|
+
"requestAnimationFrame",
|
|
275
|
+
"cancelAnimationFrame",
|
|
276
|
+
]
|
|
277
|
+
for (const name of names) {
|
|
278
|
+
previous.set(name, globals[name])
|
|
279
|
+
if (name === "window") globals[name] = windowValue
|
|
280
|
+
else if (name === "document") globals[name] = documentValue
|
|
281
|
+
else if (name === "requestAnimationFrame")
|
|
282
|
+
globals[name] = (callback: () => void) => setTimeout(callback, 0)
|
|
283
|
+
else if (name === "cancelAnimationFrame") globals[name] = (id: number) => clearTimeout(id)
|
|
284
|
+
else globals[name] = windowValue[name]
|
|
285
|
+
}
|
|
286
|
+
const windowRecord = windowValue as Record<string, unknown>
|
|
287
|
+
windowRecord.__NIFRA_ROUTE__ = globalValue(html, "__NIFRA_ROUTE__")
|
|
288
|
+
windowRecord.__NIFRA_DATA__ = globalValue(html, "__NIFRA_DATA__")
|
|
289
|
+
windowRecord.__NIFRA_LAYOUT_DATA__ = globalValue(html, "__NIFRA_LAYOUT_DATA__")
|
|
290
|
+
windowRecord.__NIFRA_ACTION__ = globalValue(html, "__NIFRA_ACTION__")
|
|
291
|
+
windowRecord.console = console
|
|
292
|
+
const errors: string[] = []
|
|
293
|
+
const recover = (error: unknown, info?: unknown): void => {
|
|
294
|
+
errors.push(`${String(error)}${info === undefined ? "" : ` ${String(info)}`}`)
|
|
295
|
+
}
|
|
296
|
+
const assuranceRecord = globalRecord[HYDRATION_ASSURANCE]
|
|
297
|
+
const runtimeRecord = globalRecord[runtimeKey]
|
|
298
|
+
globalRecord[HYDRATION_ASSURANCE] = { onRecoverableError: recover }
|
|
299
|
+
delete globalRecord[runtimeKey]
|
|
300
|
+
try {
|
|
301
|
+
const write = documentValue.write as ((value: string) => void) | undefined
|
|
302
|
+
if (write === undefined) throw new Error("DOM runner does not implement document.write")
|
|
303
|
+
write.call(documentValue, html)
|
|
304
|
+
const beforeRoot = (
|
|
305
|
+
documentValue.querySelector as (selector: string) => { innerHTML?: unknown } | null
|
|
306
|
+
)("#root")
|
|
307
|
+
const before = String(beforeRoot?.innerHTML ?? "")
|
|
308
|
+
const entry = outputPath(built.outputDir, built.client.entry)
|
|
309
|
+
const originalError = console.error
|
|
310
|
+
const originalWarn = console.warn
|
|
311
|
+
console.error = (...args) => {
|
|
312
|
+
const message = args.map(String).join(" ")
|
|
313
|
+
if (/hydr|mismatch|server|client|expected|recover/i.test(message)) errors.push(message)
|
|
314
|
+
originalError(...args)
|
|
315
|
+
}
|
|
316
|
+
console.warn = (...args) => {
|
|
317
|
+
const message = args.map(String).join(" ")
|
|
318
|
+
if (/hydr|mismatch|server|client|expected|recover/i.test(message)) errors.push(message)
|
|
319
|
+
originalWarn(...args)
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
await import(`${pathToFileURL(entry).href}?nifra-hydration=${encodeURIComponent(path)}`)
|
|
323
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 0))
|
|
324
|
+
} finally {
|
|
325
|
+
console.error = originalError
|
|
326
|
+
console.warn = originalWarn
|
|
327
|
+
}
|
|
328
|
+
const afterRoot = (
|
|
329
|
+
documentValue.querySelector as (selector: string) => { innerHTML?: unknown } | null
|
|
330
|
+
)("#root")
|
|
331
|
+
const after = String(afterRoot?.innerHTML ?? "")
|
|
332
|
+
const diagnostics: import("./diagnostics.ts").Diagnostic[] = []
|
|
333
|
+
if (errors.length > 0 || before !== after) {
|
|
334
|
+
diagnostics.push(
|
|
335
|
+
diagnostic("NF-H001", "client hydration reported a recoverable mismatch", undefined, [
|
|
336
|
+
built.framework,
|
|
337
|
+
...errors.slice(0, 3),
|
|
338
|
+
]),
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
const runtime = globalRecord[runtimeKey] as
|
|
342
|
+
| { framework?: unknown; identities?: unknown[] }
|
|
343
|
+
| undefined
|
|
344
|
+
if (
|
|
345
|
+
runtime !== undefined &&
|
|
346
|
+
Array.isArray(runtime.identities) &&
|
|
347
|
+
runtime.identities.length !== 1
|
|
348
|
+
) {
|
|
349
|
+
diagnostics.push(
|
|
350
|
+
diagnostic(
|
|
351
|
+
"NF-H002",
|
|
352
|
+
"hydration loaded more than one framework runtime identity",
|
|
353
|
+
undefined,
|
|
354
|
+
[built.framework, `identities=${runtime.identities.length}`],
|
|
355
|
+
),
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
if (interact) {
|
|
359
|
+
const probes = documentValue.querySelectorAll as
|
|
360
|
+
| ((selector: string) => { length: number; [index: number]: { click?: () => void } })
|
|
361
|
+
| undefined
|
|
362
|
+
const elements = probes?.call(documentValue, "[data-nifra-hydration-probe]")
|
|
363
|
+
if (elements !== undefined && elements.length > 0) {
|
|
364
|
+
const first = elements[0]
|
|
365
|
+
if (first?.click !== undefined) first.click()
|
|
366
|
+
const hydrated = documentValue.documentElement as {
|
|
367
|
+
hasAttribute?: (name: string) => boolean
|
|
368
|
+
}
|
|
369
|
+
if (hydrated.hasAttribute?.("data-nifra-hydrated") !== true)
|
|
370
|
+
diagnostics.push(
|
|
371
|
+
diagnostic("NF-H004", "hydration probe did not become interactive", undefined, [
|
|
372
|
+
built.framework,
|
|
373
|
+
]),
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return diagnostics
|
|
378
|
+
} finally {
|
|
379
|
+
if (assuranceRecord === undefined) delete globalRecord[HYDRATION_ASSURANCE]
|
|
380
|
+
else globalRecord[HYDRATION_ASSURANCE] = assuranceRecord
|
|
381
|
+
if (runtimeRecord === undefined) delete globalRecord[runtimeKey]
|
|
382
|
+
else globalRecord[runtimeKey] = runtimeRecord
|
|
383
|
+
for (const [name, value] of previous) {
|
|
384
|
+
if (value === undefined) delete globals[name]
|
|
385
|
+
else globals[name] = value
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function runHydrationProof(cwd: string, options: HydrationOptions): Promise<HydrationResult> {
|
|
391
|
+
const files = await sourceFiles(cwd)
|
|
392
|
+
const diagnostics: import("./diagnostics.ts").Diagnostic[] = []
|
|
393
|
+
const replays: string[] = []
|
|
394
|
+
const seed = options.seed ?? "nifra-hydration-1"
|
|
395
|
+
installDeterministicRuntime(seed)
|
|
396
|
+
const existingDist = join(cwd, "dist")
|
|
397
|
+
const latestSource = Math.max(...files.map((file) => statSync(join(cwd, file.file)).mtimeMs), 0)
|
|
398
|
+
if (existsSync(existingDist) && latestSource > statSync(existingDist).mtimeMs) {
|
|
399
|
+
const replay = await writeReplay(cwd, "dist", seed, {
|
|
400
|
+
latestSource,
|
|
401
|
+
dist: statSync(existingDist).mtimeMs,
|
|
402
|
+
})
|
|
403
|
+
replays.push(replay)
|
|
404
|
+
diagnostics.push(
|
|
405
|
+
diagnostic(
|
|
406
|
+
"NF-H003",
|
|
407
|
+
"client build output is older than the route source; rebuild before hydrating",
|
|
408
|
+
"dist",
|
|
409
|
+
[replay],
|
|
410
|
+
),
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
let built: BuiltHydrationApp | { skipReason: string }
|
|
415
|
+
try {
|
|
416
|
+
built = await buildHydrationApp(cwd)
|
|
417
|
+
} catch (error) {
|
|
418
|
+
const replay = await writeReplay(cwd, "build", seed, {
|
|
419
|
+
message: error instanceof Error ? error.message : String(error),
|
|
420
|
+
})
|
|
421
|
+
replays.push(replay)
|
|
422
|
+
return {
|
|
423
|
+
diagnostics: [
|
|
424
|
+
diagnostic(
|
|
425
|
+
"NF-H001",
|
|
426
|
+
`hydration client build failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
427
|
+
undefined,
|
|
428
|
+
[replay],
|
|
429
|
+
),
|
|
430
|
+
],
|
|
431
|
+
replays,
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if ("skipReason" in built)
|
|
435
|
+
return {
|
|
436
|
+
diagnostics,
|
|
437
|
+
...(diagnostics.length === 0 ? { skipReason: built.skipReason } : {}),
|
|
438
|
+
replays,
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const framework = built.framework
|
|
442
|
+
const cleanup = async (): Promise<void> => {
|
|
443
|
+
await rm(resolve(built.outputDir, ".."), { recursive: true, force: true })
|
|
444
|
+
}
|
|
445
|
+
const doctor = await collectDoctorResult(cwd)
|
|
446
|
+
const duplicate = doctor.duplicateInstalls.filter((finding) => finding.package === framework)
|
|
447
|
+
if (duplicate.length > 0) {
|
|
448
|
+
const replay = await writeReplay(cwd, framework, seed, {
|
|
449
|
+
framework,
|
|
450
|
+
copies: duplicate.map((item) => item.copies.length),
|
|
451
|
+
})
|
|
452
|
+
replays.push(replay)
|
|
453
|
+
diagnostics.push(
|
|
454
|
+
diagnostic(
|
|
455
|
+
"NF-H002",
|
|
456
|
+
`multiple ${framework} runtime installations reached the client build`,
|
|
457
|
+
undefined,
|
|
458
|
+
[framework, replay],
|
|
459
|
+
),
|
|
460
|
+
)
|
|
461
|
+
}
|
|
462
|
+
for (const file of files) {
|
|
463
|
+
if (!sourceBranch(file.content)) continue
|
|
464
|
+
const replay = await writeReplay(cwd, file.file, seed, { framework, source: file.file })
|
|
465
|
+
replays.push(replay)
|
|
466
|
+
// Advisory only: `typeof window` guards are the standard SSR-safe idiom and most of them never
|
|
467
|
+
// touch render output. The DOM before/after comparison below is the authoritative mismatch proof.
|
|
468
|
+
diagnostics.push(
|
|
469
|
+
diagnostic(
|
|
470
|
+
"NF-H001",
|
|
471
|
+
"source branches on window state; confirm render output is identical before hydration",
|
|
472
|
+
file.file,
|
|
473
|
+
[framework, replay],
|
|
474
|
+
"info",
|
|
475
|
+
),
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const manifest = discoverRoutes(join(cwd, "routes"))
|
|
480
|
+
const requested = options.routes?.length
|
|
481
|
+
? [...options.routes]
|
|
482
|
+
: manifest.routes.slice(0, 20).map((route) => routePath(route.pattern))
|
|
483
|
+
const requests = requested.length === 0 ? [{ path: "/" }] : requested.map((path) => ({ path }))
|
|
484
|
+
const results: unknown[] = []
|
|
485
|
+
for (const request of requests) {
|
|
486
|
+
installDeterministicRuntime(`${seed}:${request.path}`)
|
|
487
|
+
const [result] = await runApp(built.app, [request])
|
|
488
|
+
results.push(result)
|
|
489
|
+
}
|
|
490
|
+
const domModule = await loadOptionalDom(cwd)
|
|
491
|
+
if (domModule === undefined) {
|
|
492
|
+
await cleanup()
|
|
493
|
+
return diagnostics.length === 0
|
|
494
|
+
? {
|
|
495
|
+
diagnostics,
|
|
496
|
+
skipReason: "no happy-dom dependency configured for DOM hydration execution",
|
|
497
|
+
}
|
|
498
|
+
: { diagnostics, replays }
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
for (let index = 0; index < results.length; index++) {
|
|
502
|
+
const result = results[index] as { status?: number; body?: unknown }
|
|
503
|
+
if (result.status !== 200 || typeof result.body !== "string") continue
|
|
504
|
+
const route = requested[index] ?? "/"
|
|
505
|
+
try {
|
|
506
|
+
// SSR and CSR for one route start from the same deterministic clock, random stream, and timer
|
|
507
|
+
// surface. A route that reads one of them therefore either matches exactly or reports NF-H001.
|
|
508
|
+
installDeterministicRuntime(`${seed}:${route}`)
|
|
509
|
+
const pageDiagnostics = await runDomHydration(
|
|
510
|
+
cwd,
|
|
511
|
+
built,
|
|
512
|
+
result.body,
|
|
513
|
+
route,
|
|
514
|
+
options.interact === true,
|
|
515
|
+
)
|
|
516
|
+
if (pageDiagnostics.length > 0) {
|
|
517
|
+
const replay = await writeReplay(cwd, route, seed, {
|
|
518
|
+
framework,
|
|
519
|
+
route,
|
|
520
|
+
assets: built.client.assets,
|
|
521
|
+
})
|
|
522
|
+
replays.push(replay)
|
|
523
|
+
diagnostics.push(
|
|
524
|
+
...pageDiagnostics.map((item) => ({
|
|
525
|
+
...item,
|
|
526
|
+
evidence: [...(item.evidence ?? []), replay],
|
|
527
|
+
})),
|
|
528
|
+
)
|
|
529
|
+
}
|
|
530
|
+
} catch (error) {
|
|
531
|
+
const replay = await writeReplay(cwd, route, seed, {
|
|
532
|
+
framework,
|
|
533
|
+
route,
|
|
534
|
+
assets: built.client.assets,
|
|
535
|
+
})
|
|
536
|
+
replays.push(replay)
|
|
537
|
+
diagnostics.push(
|
|
538
|
+
diagnostic(
|
|
539
|
+
"NF-H001",
|
|
540
|
+
`hydration execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
541
|
+
undefined,
|
|
542
|
+
[framework, replay],
|
|
543
|
+
),
|
|
544
|
+
)
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return { diagnostics, ...(replays.length === 0 ? {} : { replays }) }
|
|
548
|
+
} finally {
|
|
549
|
+
await cleanup()
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Run hydration in a fresh process so the project's current client and framework modules are isolated. */
|
|
554
|
+
export async function runHydrationAssurance(
|
|
555
|
+
cwd: string,
|
|
556
|
+
options: HydrationOptions = {},
|
|
557
|
+
): Promise<HydrationResult> {
|
|
558
|
+
const root = resolve(cwd)
|
|
559
|
+
const entry = fileURLToPath(import.meta.url)
|
|
560
|
+
const proc = Bun.spawn(
|
|
561
|
+
[process.execPath, entry, root, "--nifra-hydration-child", JSON.stringify(options)],
|
|
562
|
+
{
|
|
563
|
+
stdin: "ignore",
|
|
564
|
+
stdout: "pipe",
|
|
565
|
+
stderr: "pipe",
|
|
566
|
+
},
|
|
567
|
+
)
|
|
568
|
+
const [stdout, stderr] = await Promise.all([
|
|
569
|
+
new Response(proc.stdout).text(),
|
|
570
|
+
new Response(proc.stderr).text(),
|
|
571
|
+
])
|
|
572
|
+
await proc.exited
|
|
573
|
+
try {
|
|
574
|
+
return JSON.parse(stdout) as HydrationResult
|
|
575
|
+
} catch {
|
|
576
|
+
return {
|
|
577
|
+
diagnostics: [
|
|
578
|
+
diagnostic(
|
|
579
|
+
"NF-H001",
|
|
580
|
+
`hydration runner failed: ${stderr.trim() || stdout.trim() || "no result"}`,
|
|
581
|
+
),
|
|
582
|
+
],
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (import.meta.main && process.argv.includes("--nifra-hydration-child")) {
|
|
588
|
+
const cwd = process.argv[2] ?? process.cwd()
|
|
589
|
+
const rawOptions = process.argv[4]
|
|
590
|
+
try {
|
|
591
|
+
process.chdir(cwd)
|
|
592
|
+
const options = rawOptions === undefined ? {} : (JSON.parse(rawOptions) as HydrationOptions)
|
|
593
|
+
const result = await runHydrationProof(cwd, options)
|
|
594
|
+
await Bun.write(Bun.stdout, JSON.stringify(result))
|
|
595
|
+
} catch (error) {
|
|
596
|
+
await Bun.write(
|
|
597
|
+
Bun.stdout,
|
|
598
|
+
JSON.stringify({
|
|
599
|
+
diagnostics: [
|
|
600
|
+
diagnostic("NF-H001", error instanceof Error ? error.message : String(error)),
|
|
601
|
+
],
|
|
602
|
+
}),
|
|
603
|
+
)
|
|
604
|
+
}
|
|
605
|
+
process.exit(0)
|
|
606
|
+
}
|