@nifrajs/cli 2.11.0 → 2.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +6 -0
  2. package/dist/assure-hydration.d.ts +14 -0
  3. package/dist/assure-hydration.d.ts.map +1 -0
  4. package/dist/assure-hydration.js +483 -0
  5. package/dist/assure-hydration.js.map +1 -0
  6. package/dist/assure.d.ts +35 -0
  7. package/dist/assure.d.ts.map +1 -1
  8. package/dist/assure.js +222 -1
  9. package/dist/assure.js.map +1 -1
  10. package/dist/capabilities-tool.d.ts +13 -0
  11. package/dist/capabilities-tool.d.ts.map +1 -1
  12. package/dist/capabilities-tool.js +64 -1
  13. package/dist/capabilities-tool.js.map +1 -1
  14. package/dist/check.d.ts +125 -10
  15. package/dist/check.d.ts.map +1 -1
  16. package/dist/check.js +706 -145
  17. package/dist/check.js.map +1 -1
  18. package/dist/cli.d.ts +23 -0
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +194 -10
  21. package/dist/cli.js.map +1 -1
  22. package/dist/contract-proof.d.ts +49 -0
  23. package/dist/contract-proof.d.ts.map +1 -0
  24. package/dist/contract-proof.js +128 -0
  25. package/dist/contract-proof.js.map +1 -0
  26. package/dist/contracts.d.ts +22 -0
  27. package/dist/contracts.d.ts.map +1 -0
  28. package/dist/contracts.js +134 -0
  29. package/dist/contracts.js.map +1 -0
  30. package/dist/diagnostics.d.ts +23 -0
  31. package/dist/diagnostics.d.ts.map +1 -0
  32. package/dist/diagnostics.js +9 -0
  33. package/dist/diagnostics.js.map +1 -0
  34. package/dist/diff-tool.d.ts +2 -0
  35. package/dist/diff-tool.d.ts.map +1 -1
  36. package/dist/diff-tool.js +1 -1
  37. package/dist/diff-tool.js.map +1 -1
  38. package/dist/docs-search.d.ts +8 -3
  39. package/dist/docs-search.d.ts.map +1 -1
  40. package/dist/docs-search.js +36 -15
  41. package/dist/docs-search.js.map +1 -1
  42. package/dist/doctor.d.ts +28 -2
  43. package/dist/doctor.d.ts.map +1 -1
  44. package/dist/doctor.js +173 -7
  45. package/dist/doctor.js.map +1 -1
  46. package/dist/examples.d.ts +1 -2
  47. package/dist/examples.d.ts.map +1 -1
  48. package/dist/examples.js +37 -16
  49. package/dist/examples.js.map +1 -1
  50. package/dist/fix-recipes.d.ts +12 -0
  51. package/dist/fix-recipes.d.ts.map +1 -0
  52. package/dist/fix-recipes.js +106 -0
  53. package/dist/fix-recipes.js.map +1 -0
  54. package/dist/init-agents.d.ts.map +1 -1
  55. package/dist/init-agents.js +60 -4
  56. package/dist/init-agents.js.map +1 -1
  57. package/dist/internal/source-facts.d.ts +15 -0
  58. package/dist/internal/source-facts.d.ts.map +1 -0
  59. package/dist/internal/source-facts.js +82 -0
  60. package/dist/internal/source-facts.js.map +1 -0
  61. package/dist/internal/typescript-import.d.ts +9 -0
  62. package/dist/internal/typescript-import.d.ts.map +1 -1
  63. package/dist/internal/typescript-import.js +38 -0
  64. package/dist/internal/typescript-import.js.map +1 -1
  65. package/dist/introspect.d.ts +6 -2
  66. package/dist/introspect.d.ts.map +1 -1
  67. package/dist/introspect.js +53 -4
  68. package/dist/introspect.js.map +1 -1
  69. package/dist/load.d.ts +6 -0
  70. package/dist/load.d.ts.map +1 -1
  71. package/dist/load.js.map +1 -1
  72. package/dist/manifest-tool.d.ts.map +1 -1
  73. package/dist/manifest-tool.js +14 -9
  74. package/dist/manifest-tool.js.map +1 -1
  75. package/dist/mcp-docs-tools.d.ts.map +1 -1
  76. package/dist/mcp-docs-tools.js +3 -0
  77. package/dist/mcp-docs-tools.js.map +1 -1
  78. package/dist/mcp-http.js +1 -1
  79. package/dist/mcp-root.d.ts +83 -0
  80. package/dist/mcp-root.d.ts.map +1 -0
  81. package/dist/mcp-root.js +221 -0
  82. package/dist/mcp-root.js.map +1 -0
  83. package/dist/mcp-run.d.ts.map +1 -1
  84. package/dist/mcp-run.js +18 -4
  85. package/dist/mcp-run.js.map +1 -1
  86. package/dist/mcp.d.ts +27 -4
  87. package/dist/mcp.d.ts.map +1 -1
  88. package/dist/mcp.js +358 -25
  89. package/dist/mcp.js.map +1 -1
  90. package/dist/project-path.d.ts +3 -0
  91. package/dist/project-path.d.ts.map +1 -0
  92. package/dist/project-path.js +36 -0
  93. package/dist/project-path.js.map +1 -0
  94. package/dist/replay.d.ts +12 -0
  95. package/dist/replay.d.ts.map +1 -0
  96. package/dist/replay.js +36 -0
  97. package/dist/replay.js.map +1 -0
  98. package/dist/rules/codes.d.ts +42 -0
  99. package/dist/rules/codes.d.ts.map +1 -0
  100. package/dist/rules/codes.js +52 -0
  101. package/dist/rules/codes.js.map +1 -0
  102. package/dist/rules/comment-markers.d.ts +10 -0
  103. package/dist/rules/comment-markers.d.ts.map +1 -0
  104. package/dist/rules/comment-markers.js +24 -0
  105. package/dist/rules/comment-markers.js.map +1 -0
  106. package/dist/rules/index.d.ts +31 -0
  107. package/dist/rules/index.d.ts.map +1 -0
  108. package/dist/rules/index.js +133 -0
  109. package/dist/rules/index.js.map +1 -0
  110. package/dist/rules/legacy.d.ts +7 -0
  111. package/dist/rules/legacy.d.ts.map +1 -0
  112. package/dist/rules/legacy.js +85 -0
  113. package/dist/rules/legacy.js.map +1 -0
  114. package/dist/rules/routes.d.ts +12 -0
  115. package/dist/rules/routes.d.ts.map +1 -0
  116. package/dist/rules/routes.js +142 -0
  117. package/dist/rules/routes.js.map +1 -0
  118. package/dist/rules/security.d.ts +10 -0
  119. package/dist/rules/security.d.ts.map +1 -0
  120. package/dist/rules/security.js +471 -0
  121. package/dist/rules/security.js.map +1 -0
  122. package/dist/search-terms.d.ts +2 -0
  123. package/dist/search-terms.d.ts.map +1 -1
  124. package/dist/search-terms.js +5 -1
  125. package/dist/search-terms.js.map +1 -1
  126. package/dist/types-search.d.ts +1 -2
  127. package/dist/types-search.d.ts.map +1 -1
  128. package/dist/types-search.js +33 -16
  129. package/dist/types-search.js.map +1 -1
  130. package/dist/verification.d.ts +9 -0
  131. package/dist/verification.d.ts.map +1 -1
  132. package/dist/verification.js +17 -1
  133. package/dist/verification.js.map +1 -1
  134. package/dist/work-graph.d.ts +123 -0
  135. package/dist/work-graph.d.ts.map +1 -0
  136. package/dist/work-graph.js +465 -0
  137. package/dist/work-graph.js.map +1 -0
  138. package/docs/examples.json +32 -2
  139. package/docs/llms-full.txt +906 -15
  140. package/docs/types.json +1682 -130
  141. package/package.json +34 -9
  142. package/src/assure-hydration.ts +606 -0
  143. package/src/assure.ts +316 -2
  144. package/src/capabilities-tool.ts +80 -1
  145. package/src/check.ts +873 -168
  146. package/src/cli.ts +198 -11
  147. package/src/contract-proof.ts +194 -0
  148. package/src/contracts.ts +157 -0
  149. package/src/diagnostics.ts +32 -0
  150. package/src/diff-tool.ts +1 -1
  151. package/src/docs-search.ts +45 -15
  152. package/src/doctor.ts +259 -8
  153. package/src/examples.ts +43 -15
  154. package/src/fix-recipes.ts +126 -0
  155. package/src/init-agents.ts +61 -4
  156. package/src/internal/source-facts.ts +133 -0
  157. package/src/internal/typescript-import.ts +29 -0
  158. package/src/introspect.ts +75 -6
  159. package/src/load.ts +6 -0
  160. package/src/manifest-tool.ts +16 -10
  161. package/src/mcp-docs-tools.ts +3 -0
  162. package/src/mcp-http.ts +1 -1
  163. package/src/mcp-root.ts +271 -0
  164. package/src/mcp-run.ts +19 -4
  165. package/src/mcp.ts +438 -32
  166. package/src/project-path.ts +36 -0
  167. package/src/replay.ts +44 -0
  168. package/src/rules/codes.ts +57 -0
  169. package/src/rules/comment-markers.ts +26 -0
  170. package/src/rules/index.ts +175 -0
  171. package/src/rules/legacy.ts +93 -0
  172. package/src/rules/routes.ts +157 -0
  173. package/src/rules/security.ts +505 -0
  174. package/src/search-terms.ts +9 -4
  175. package/src/types-search.ts +39 -15
  176. package/src/verification.ts +18 -1
  177. 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.11.0",
3
+ "version": "2.12.1",
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.11.0",
45
- "@nifrajs/core": "2.11.0",
46
- "@nifrajs/mcp": "2.11.0",
47
- "@nifrajs/runner": "2.11.0",
48
- "@nifrajs/schema": "2.11.0",
49
- "@nifrajs/testing": "2.11.0",
50
- "@nifrajs/web": "2.11.0",
51
- "create-nifra": "2.11.0"
69
+ "@nifrajs/client": "2.12.1",
70
+ "@nifrajs/core": "2.12.1",
71
+ "@nifrajs/mcp": "2.12.1",
72
+ "@nifrajs/runner": "2.12.1",
73
+ "@nifrajs/schema": "2.12.1",
74
+ "@nifrajs/testing": "2.12.1",
75
+ "@nifrajs/web": "2.12.1",
76
+ "create-nifra": "2.12.1"
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
+ }