rankforge 0.3.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/LICENSE +21 -0
- package/README.md +30 -0
- package/package.json +49 -0
- package/src/audit-output-schema.mjs +88 -0
- package/src/audit.mjs +202 -0
- package/src/cli.mjs +508 -0
- package/src/config-schema.mjs +292 -0
- package/src/crawl.mjs +188 -0
- package/src/finding-task.mjs +9 -0
- package/src/html-extract.mjs +226 -0
- package/src/index.mjs +9 -0
- package/src/integrations.mjs +78 -0
- package/src/io-guards.mjs +196 -0
- package/src/performance.mjs +112 -0
- package/src/regex-guards.mjs +52 -0
- package/src/render-parity.mjs +149 -0
- package/src/render.mjs +45 -0
- package/src/repo-audit.mjs +429 -0
- package/src/repo-detect.mjs +87 -0
- package/src/repo-findings.mjs +9 -0
- package/src/repo-manifests.mjs +169 -0
- package/src/repo-process.mjs +298 -0
- package/src/repo-routes.mjs +46 -0
- package/src/report.mjs +898 -0
- package/src/robots.mjs +60 -0
- package/src/rule-depth.mjs +190 -0
- package/src/rule-engine.mjs +360 -0
- package/src/rules.mjs +350 -0
- package/src/site-rule-engine.mjs +177 -0
- package/src/sitemap.mjs +30 -0
- package/src/snapshot.mjs +119 -0
- package/src/source-map.json +28 -0
- package/src/structured-data.mjs +59 -0
- package/src/url-utils.mjs +25 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { discoverStaticRoutes } from "./repo-routes.mjs";
|
|
4
|
+
|
|
5
|
+
const staticDirCandidates = ["dist", "build", "out", "public"];
|
|
6
|
+
|
|
7
|
+
const frameworkSignals = [
|
|
8
|
+
["next", "next"],
|
|
9
|
+
["astro", "astro"],
|
|
10
|
+
["@sveltejs/kit", "@sveltejs/kit"],
|
|
11
|
+
["@remix-run/node", "@remix-run/node"],
|
|
12
|
+
["vite", "vite"],
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const readPackageJson = (repoRoot) => {
|
|
16
|
+
const packageJsonPath = path.join(repoRoot, "package.json");
|
|
17
|
+
if (!fs.existsSync(packageJsonPath)) return null;
|
|
18
|
+
return JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const detectPackageManager = (repoRoot) => {
|
|
22
|
+
if (fs.existsSync(path.join(repoRoot, "pnpm-lock.yaml"))) return "pnpm";
|
|
23
|
+
if (fs.existsSync(path.join(repoRoot, "yarn.lock"))) return "yarn";
|
|
24
|
+
if (fs.existsSync(path.join(repoRoot, "package-lock.json"))) return "npm";
|
|
25
|
+
if (fs.existsSync(path.join(repoRoot, "package.json"))) return "npm";
|
|
26
|
+
return null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const scriptCommand = (packageManager, scriptName, packageJson) => {
|
|
30
|
+
if (!packageManager || !packageJson?.scripts?.[scriptName]) return null;
|
|
31
|
+
return `${packageManager} run ${scriptName}`;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const dependenciesFor = (packageJson) => ({
|
|
35
|
+
...packageJson?.dependencies,
|
|
36
|
+
...packageJson?.devDependencies,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const detectFramework = (packageJson, hasStaticOutput) => {
|
|
40
|
+
const dependencies = dependenciesFor(packageJson);
|
|
41
|
+
for (const [dependencyName, framework] of frameworkSignals) {
|
|
42
|
+
if (dependencies[dependencyName]) {
|
|
43
|
+
return { detectedFramework: framework, confidence: "high" };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (packageJson) {
|
|
48
|
+
return { detectedFramework: "generic-node", confidence: "medium" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (hasStaticOutput) {
|
|
52
|
+
return { detectedFramework: "generic-static", confidence: "medium" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { detectedFramework: null, confidence: "low" };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const detectStaticDir = (repoRoot) => {
|
|
59
|
+
for (const dirRelative of staticDirCandidates) {
|
|
60
|
+
const dir = path.join(repoRoot, dirRelative);
|
|
61
|
+
if (fs.existsSync(path.join(dir, "index.html"))) {
|
|
62
|
+
return { staticDir: dir, staticDirRelative: dirRelative };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { staticDir: null, staticDirRelative: null };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const detectRepo = (repoRoot, options = {}) => {
|
|
70
|
+
const resolvedRepoRoot = path.resolve(repoRoot);
|
|
71
|
+
const packageJson = options.packageJson ?? readPackageJson(resolvedRepoRoot);
|
|
72
|
+
const packageManager = detectPackageManager(resolvedRepoRoot);
|
|
73
|
+
const { staticDir, staticDirRelative } = detectStaticDir(resolvedRepoRoot);
|
|
74
|
+
const { detectedFramework, confidence } = detectFramework(packageJson, Boolean(staticDir));
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
repoRoot: resolvedRepoRoot,
|
|
78
|
+
detectedFramework,
|
|
79
|
+
confidence,
|
|
80
|
+
packageManager,
|
|
81
|
+
buildCommand: scriptCommand(packageManager, "build", packageJson),
|
|
82
|
+
previewCommand: scriptCommand(packageManager, "preview", packageJson),
|
|
83
|
+
staticDir,
|
|
84
|
+
staticDirRelative,
|
|
85
|
+
routeSources: staticDir ? discoverStaticRoutes(staticDir) : [],
|
|
86
|
+
};
|
|
87
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { sourceFinding } from "./repo-findings.mjs";
|
|
4
|
+
|
|
5
|
+
const manifestConfigs = {
|
|
6
|
+
next: {
|
|
7
|
+
type: "next_prerender_manifest",
|
|
8
|
+
relativePath: path.join(".next", "prerender-manifest.json"),
|
|
9
|
+
routesFor: (json) =>
|
|
10
|
+
json?.routes && typeof json.routes === "object" && !Array.isArray(json.routes) ? Object.keys(json.routes) : null,
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const normalizeRoute = (route) => {
|
|
15
|
+
if (typeof route !== "string") return null;
|
|
16
|
+
const cleanRoute = route.trim();
|
|
17
|
+
if (!cleanRoute) return null;
|
|
18
|
+
const withLeadingSlash = cleanRoute.startsWith("/") ? cleanRoute : `/${cleanRoute}`;
|
|
19
|
+
if (withLeadingSlash === "/") return "/";
|
|
20
|
+
if (withLeadingSlash.endsWith("/") || withLeadingSlash.endsWith(".html")) return withLeadingSlash;
|
|
21
|
+
return `${withLeadingSlash}/`;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const routeEntry = (route) => {
|
|
25
|
+
if (typeof route !== "string") return null;
|
|
26
|
+
const cleanRoute = route.trim();
|
|
27
|
+
const normalizedRoute = normalizeRoute(cleanRoute);
|
|
28
|
+
if (!normalizedRoute) return null;
|
|
29
|
+
|
|
30
|
+
const withLeadingSlash = cleanRoute.startsWith("/") ? cleanRoute : `/${cleanRoute}`;
|
|
31
|
+
return {
|
|
32
|
+
route: normalizedRoute,
|
|
33
|
+
extensionless: withLeadingSlash !== "/" && !withLeadingSlash.endsWith("/") && !withLeadingSlash.endsWith(".html"),
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const uniqueRouteEntries = (routes) => {
|
|
38
|
+
const entries = [];
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
for (const route of routes) {
|
|
41
|
+
const entry = routeEntry(route);
|
|
42
|
+
if (!entry) continue;
|
|
43
|
+
const key = `${entry.route}:${entry.extensionless ? "extensionless" : "explicit"}`;
|
|
44
|
+
if (seen.has(key)) continue;
|
|
45
|
+
seen.add(key);
|
|
46
|
+
entries.push(entry);
|
|
47
|
+
}
|
|
48
|
+
return entries;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const uniqueNormalizedRoutes = (routes) => {
|
|
52
|
+
const normalized = [];
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
for (const { route } of routes) {
|
|
55
|
+
if (seen.has(route)) continue;
|
|
56
|
+
seen.add(route);
|
|
57
|
+
normalized.push(route);
|
|
58
|
+
}
|
|
59
|
+
return normalized;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const uniqueNormalizedRouteStrings = (routes) => {
|
|
63
|
+
const normalized = [];
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
for (const route of routes) {
|
|
66
|
+
const normalizedRoute = normalizeRoute(route);
|
|
67
|
+
if (!normalizedRoute || seen.has(normalizedRoute)) continue;
|
|
68
|
+
seen.add(normalizedRoute);
|
|
69
|
+
normalized.push(normalizedRoute);
|
|
70
|
+
}
|
|
71
|
+
return normalized;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const isPathInside = (root, candidate) => {
|
|
75
|
+
const relative = path.relative(path.resolve(root), path.resolve(candidate));
|
|
76
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const htmlPathsForRoute = (staticDir, { route, extensionless = false }) => {
|
|
80
|
+
if (!staticDir) return [];
|
|
81
|
+
const safePaths = (candidates) => candidates.filter((candidate) => isPathInside(staticDir, candidate));
|
|
82
|
+
if (route === "/") return safePaths([path.join(staticDir, "index.html")]);
|
|
83
|
+
|
|
84
|
+
const relativeRoute = route.startsWith("/") ? route.slice(1) : route;
|
|
85
|
+
if (relativeRoute.endsWith(".html")) return safePaths([path.join(staticDir, relativeRoute)]);
|
|
86
|
+
const directoryHtmlPath = path.join(staticDir, relativeRoute, "index.html");
|
|
87
|
+
if (!extensionless) return safePaths([directoryHtmlPath]);
|
|
88
|
+
return safePaths([directoryHtmlPath, path.join(staticDir, relativeRoute.replace(/\/$/, ".html"))]);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const hasGeneratedHtmlForRoute = (staticDir, entry) =>
|
|
92
|
+
htmlPathsForRoute(staticDir, entry).some((htmlPath) => fs.existsSync(htmlPath) && fs.statSync(htmlPath).isFile());
|
|
93
|
+
|
|
94
|
+
const extensionlessHtmlRoute = (route) => (route !== "/" && route.endsWith("/") ? `${route.slice(0, -1)}.html` : null);
|
|
95
|
+
|
|
96
|
+
const isStaticRouteListed = (staticRoute, manifestEntries) => {
|
|
97
|
+
for (const entry of manifestEntries) {
|
|
98
|
+
if (entry.route === staticRoute) return true;
|
|
99
|
+
if (entry.extensionless && extensionlessHtmlRoute(entry.route) === staticRoute) return true;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const invalidManifestFinding = (manifestPath, error) =>
|
|
105
|
+
sourceFinding({
|
|
106
|
+
id: "repo.route_manifest_invalid",
|
|
107
|
+
message: "Framework route manifest could not be parsed.",
|
|
108
|
+
evidence: manifestPath,
|
|
109
|
+
recommendation: "Regenerate the framework build output and rerun the repository audit.",
|
|
110
|
+
details: { message: error?.message },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const manifestRouteMissingFinding = (route) =>
|
|
114
|
+
sourceFinding({
|
|
115
|
+
id: "repo.manifest_route_missing",
|
|
116
|
+
message: "Framework route manifest lists a route that is missing from static output.",
|
|
117
|
+
evidence: route,
|
|
118
|
+
recommendation: "Ensure the framework build emits this route or remove it from generated route metadata.",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const staticRouteUnlistedFinding = (route) =>
|
|
122
|
+
sourceFinding({
|
|
123
|
+
id: "repo.static_route_unlisted",
|
|
124
|
+
message: "Static output includes a route that is not listed in the framework route manifest.",
|
|
125
|
+
evidence: route,
|
|
126
|
+
recommendation: "Confirm the generated route is intentional and represented in framework route metadata.",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
export const analyzeFrameworkRouteManifests = ({ repoPath, staticDir, detectedFramework, staticRoutes = [] }) => {
|
|
130
|
+
const config = manifestConfigs[detectedFramework];
|
|
131
|
+
const frameworkManifests = [];
|
|
132
|
+
const sourceFindings = [];
|
|
133
|
+
if (!config) return { frameworkManifests, sourceFindings };
|
|
134
|
+
|
|
135
|
+
const manifestPath = path.join(repoPath, config.relativePath);
|
|
136
|
+
if (!fs.existsSync(manifestPath)) return { frameworkManifests, sourceFindings };
|
|
137
|
+
|
|
138
|
+
let json;
|
|
139
|
+
try {
|
|
140
|
+
json = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
141
|
+
} catch (error) {
|
|
142
|
+
return {
|
|
143
|
+
frameworkManifests,
|
|
144
|
+
sourceFindings: [invalidManifestFinding(manifestPath, error)],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const routes = config.routesFor(json);
|
|
149
|
+
if (!routes) return { frameworkManifests, sourceFindings };
|
|
150
|
+
|
|
151
|
+
const manifestEntries = uniqueRouteEntries(routes);
|
|
152
|
+
const manifestRoutes = uniqueNormalizedRoutes(manifestEntries);
|
|
153
|
+
frameworkManifests.push({
|
|
154
|
+
type: config.type,
|
|
155
|
+
path: manifestPath,
|
|
156
|
+
routes: manifestRoutes,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const staticRouteSet = new Set(uniqueNormalizedRouteStrings(staticRoutes.map((route) => route.route)));
|
|
160
|
+
|
|
161
|
+
for (const entry of manifestEntries) {
|
|
162
|
+
if (!hasGeneratedHtmlForRoute(staticDir, entry)) sourceFindings.push(manifestRouteMissingFinding(entry.route));
|
|
163
|
+
}
|
|
164
|
+
for (const route of staticRouteSet) {
|
|
165
|
+
if (!isStaticRouteListed(route, manifestEntries)) sourceFindings.push(staticRouteUnlistedFinding(route));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { frameworkManifests, sourceFindings };
|
|
169
|
+
};
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { once } from "node:events";
|
|
3
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
|
+
import { fetchWithGuards } from "./io-guards.mjs";
|
|
5
|
+
|
|
6
|
+
const outputCaptureLimitBytes = 64 * 1024;
|
|
7
|
+
const pollIntervalMs = 50;
|
|
8
|
+
const fetchAttemptTimeoutMs = 500;
|
|
9
|
+
const preflightTimeoutMs = 250;
|
|
10
|
+
const shutdownGraceMs = 500;
|
|
11
|
+
|
|
12
|
+
const isExited = (child) => child.exitCode !== null || child.signalCode !== null;
|
|
13
|
+
|
|
14
|
+
const waitForExit = async (child, timeoutMs) => {
|
|
15
|
+
if (isExited(child)) {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let timer;
|
|
20
|
+
try {
|
|
21
|
+
return await Promise.race([
|
|
22
|
+
once(child, "exit").then(() => true),
|
|
23
|
+
new Promise((resolve) => {
|
|
24
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
25
|
+
}),
|
|
26
|
+
]);
|
|
27
|
+
} finally {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const killChild = (child, signal) => {
|
|
33
|
+
if (!child.pid || isExited(child)) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
if (process.platform === "win32") {
|
|
39
|
+
child.kill(signal);
|
|
40
|
+
} else {
|
|
41
|
+
process.kill(-child.pid, signal);
|
|
42
|
+
}
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error?.code !== "ESRCH") {
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const capStringByBytes = (value, maxBytes) => {
|
|
51
|
+
if (Buffer.byteLength(value) <= maxBytes) {
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
return Buffer.from(value).subarray(-maxBytes).toString();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const appendCappedChunk = (chunks, chunk) => {
|
|
58
|
+
const capped = capStringByBytes(`${chunks.join("")}${String(chunk)}`, outputCaptureLimitBytes);
|
|
59
|
+
chunks.splice(0, chunks.length, capped);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const stderrTail = (stderr) => {
|
|
63
|
+
const tail = stderr.join("").trim();
|
|
64
|
+
return tail ? ` Stderr: ${tail}` : "";
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const previewError = (message, preview) => {
|
|
68
|
+
const error = new Error(message);
|
|
69
|
+
error.preview = preview;
|
|
70
|
+
return error;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const earlyExitError = (preview, code, signal) =>
|
|
74
|
+
previewError(
|
|
75
|
+
`Preview command exited before server became reachable (${code === null ? `signal ${signal}` : `code ${code}`}).${stderrTail(preview.stderr)}`,
|
|
76
|
+
preview,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const commandExecutionDisabledError = () =>
|
|
80
|
+
new Error("Restricted security mode disables local command execution for repo audits.");
|
|
81
|
+
|
|
82
|
+
const commandError = (message, commandResult) => {
|
|
83
|
+
const error = new Error(message);
|
|
84
|
+
error.commandResult = commandResult;
|
|
85
|
+
return error;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const runCommand = async ({ command, cwd, timeoutMs = 120000, label = "Command", security }) => {
|
|
89
|
+
if (!command) {
|
|
90
|
+
throw new Error(`${label} command is required.`);
|
|
91
|
+
}
|
|
92
|
+
if (security?.mode === "restricted") {
|
|
93
|
+
throw commandExecutionDisabledError();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const child = spawn(command, {
|
|
97
|
+
cwd,
|
|
98
|
+
shell: true,
|
|
99
|
+
detached: process.platform !== "win32",
|
|
100
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const startedAt = Date.now();
|
|
104
|
+
const commandResult = {
|
|
105
|
+
command,
|
|
106
|
+
stdout: [],
|
|
107
|
+
stderr: [],
|
|
108
|
+
exitCode: null,
|
|
109
|
+
signal: null,
|
|
110
|
+
durationMs: 0,
|
|
111
|
+
timedOut: false,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
child.stdout?.on("data", (chunk) => appendCappedChunk(commandResult.stdout, chunk));
|
|
115
|
+
child.stderr?.on("data", (chunk) => appendCappedChunk(commandResult.stderr, chunk));
|
|
116
|
+
|
|
117
|
+
let timeout;
|
|
118
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
119
|
+
timeout = setTimeout(() => {
|
|
120
|
+
commandResult.timedOut = true;
|
|
121
|
+
void (async () => {
|
|
122
|
+
try {
|
|
123
|
+
await stopPreview({ child });
|
|
124
|
+
} catch {
|
|
125
|
+
// Keep the timeout error as the actionable failure.
|
|
126
|
+
}
|
|
127
|
+
commandResult.durationMs = Date.now() - startedAt;
|
|
128
|
+
reject(commandError(`${label} command timed out after ${timeoutMs} ms.`, commandResult));
|
|
129
|
+
})();
|
|
130
|
+
}, timeoutMs);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const exitPromise = new Promise((resolve, reject) => {
|
|
134
|
+
child.once("error", (error) => {
|
|
135
|
+
commandResult.durationMs = Date.now() - startedAt;
|
|
136
|
+
reject(commandError(`${label} command failed to start: ${error.message}`, commandResult));
|
|
137
|
+
});
|
|
138
|
+
child.once("close", (code, signal) => {
|
|
139
|
+
commandResult.exitCode = code;
|
|
140
|
+
commandResult.signal = signal;
|
|
141
|
+
commandResult.durationMs = Date.now() - startedAt;
|
|
142
|
+
if (commandResult.timedOut) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (code === 0) {
|
|
146
|
+
resolve(commandResult);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
reject(
|
|
150
|
+
commandError(
|
|
151
|
+
`${label} command exited with ${code === null ? `signal ${signal}` : `code ${code}`}.`,
|
|
152
|
+
commandResult,
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
return await Promise.race([exitPromise, timeoutPromise]);
|
|
160
|
+
} finally {
|
|
161
|
+
clearTimeout(timeout);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const isSecurityGuardError = (error) => String(error?.message || "").startsWith("Restricted security mode ");
|
|
166
|
+
|
|
167
|
+
export const waitForHttp = async (url, options = {}) => {
|
|
168
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
169
|
+
const deadline = Date.now() + timeoutMs;
|
|
170
|
+
let lastError;
|
|
171
|
+
|
|
172
|
+
while (true) {
|
|
173
|
+
const remainingMs = deadline - Date.now();
|
|
174
|
+
if (remainingMs <= 0) {
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const attemptTimeoutMs = Math.min(fetchAttemptTimeoutMs, remainingMs);
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const response = await fetchWithGuards(url, {
|
|
182
|
+
security: options.security,
|
|
183
|
+
limits: { ...(options.limits || {}), timeoutMs: attemptTimeoutMs },
|
|
184
|
+
fetchOptions: { redirect: "manual" },
|
|
185
|
+
});
|
|
186
|
+
if (response.status < 500) {
|
|
187
|
+
await response.body?.cancel();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
await response.body?.cancel();
|
|
191
|
+
lastError = new Error(`HTTP ${response.status}`);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (isSecurityGuardError(error)) throw error;
|
|
194
|
+
lastError = error;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const suffix = lastError?.message ? ` Last error: ${lastError.message}` : "";
|
|
201
|
+
throw new Error(`Preview server did not become reachable at ${url}.${suffix}`);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export const startPreview = async ({ command, cwd, previewUrl, timeoutMs = 30000, security, limits }) => {
|
|
205
|
+
if (!command) {
|
|
206
|
+
throw new Error("--preview-command is required for preview repo audits.");
|
|
207
|
+
}
|
|
208
|
+
if (!previewUrl) {
|
|
209
|
+
throw new Error("--preview-url is required for preview repo audits.");
|
|
210
|
+
}
|
|
211
|
+
if (security?.mode === "restricted") {
|
|
212
|
+
throw commandExecutionDisabledError();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let previewUrlAlreadyReachable = false;
|
|
216
|
+
try {
|
|
217
|
+
await waitForHttp(previewUrl, { timeoutMs: preflightTimeoutMs, security, limits });
|
|
218
|
+
previewUrlAlreadyReachable = true;
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (isSecurityGuardError(error)) throw error;
|
|
221
|
+
previewUrlAlreadyReachable = false;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (previewUrlAlreadyReachable) {
|
|
225
|
+
throw new Error(`Preview URL is already reachable before starting command: ${previewUrl}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const child = spawn(command, {
|
|
229
|
+
cwd,
|
|
230
|
+
shell: true,
|
|
231
|
+
detached: process.platform !== "win32",
|
|
232
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const preview = {
|
|
236
|
+
child,
|
|
237
|
+
url: previewUrl,
|
|
238
|
+
stdout: [],
|
|
239
|
+
stderr: [],
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
child.stdout?.on("data", (chunk) => appendCappedChunk(preview.stdout, chunk));
|
|
243
|
+
child.stderr?.on("data", (chunk) => appendCappedChunk(preview.stderr, chunk));
|
|
244
|
+
|
|
245
|
+
const startupError = new Promise((_, reject) => {
|
|
246
|
+
child.once("error", (error) => {
|
|
247
|
+
reject(previewError(`Preview command failed to start: ${error.message}`, preview));
|
|
248
|
+
});
|
|
249
|
+
child.once("close", (code, signal) => {
|
|
250
|
+
reject(earlyExitError(preview, code, signal));
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
await Promise.race([waitForHttp(previewUrl, { timeoutMs, security, limits }), startupError]);
|
|
256
|
+
if (isExited(child)) {
|
|
257
|
+
throw earlyExitError(preview, child.exitCode, child.signalCode);
|
|
258
|
+
}
|
|
259
|
+
} catch (error) {
|
|
260
|
+
try {
|
|
261
|
+
await stopPreview(preview);
|
|
262
|
+
} catch {
|
|
263
|
+
// Preserve the startup failure, which is the actionable error for callers.
|
|
264
|
+
}
|
|
265
|
+
error.preview ??= preview;
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return preview;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
export const stopPreview = async (preview) => {
|
|
273
|
+
const child = preview?.child;
|
|
274
|
+
if (!child || isExited(child)) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (preview.stopPromise) {
|
|
279
|
+
return preview.stopPromise;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
preview.stopPromise = (async () => {
|
|
283
|
+
if (isExited(child)) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
killChild(child, "SIGTERM");
|
|
288
|
+
const terminated = await waitForExit(child, shutdownGraceMs);
|
|
289
|
+
if (terminated || isExited(child)) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
killChild(child, "SIGKILL");
|
|
294
|
+
await waitForExit(child, shutdownGraceMs);
|
|
295
|
+
})();
|
|
296
|
+
|
|
297
|
+
return preview.stopPromise;
|
|
298
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const ordinalCompare = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
|
|
5
|
+
|
|
6
|
+
const htmlFiles = (dir) => {
|
|
7
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true }).sort((left, right) => ordinalCompare(left.name, right.name));
|
|
8
|
+
const files = [];
|
|
9
|
+
|
|
10
|
+
for (const entry of entries) {
|
|
11
|
+
const itemPath = path.join(dir, entry.name);
|
|
12
|
+
if (entry.isDirectory()) {
|
|
13
|
+
files.push(...htmlFiles(itemPath));
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (entry.isFile() && entry.name.endsWith(".html")) files.push(itemPath);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return files;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const routeFor = (root, file) => {
|
|
23
|
+
const relative = path.relative(root, file);
|
|
24
|
+
const parsed = path.parse(relative);
|
|
25
|
+
const routePath = relative.split(path.sep).join("/");
|
|
26
|
+
|
|
27
|
+
if (routePath === "index.html") return "/";
|
|
28
|
+
if (parsed.base === "index.html") return `/${parsed.dir.split(path.sep).join("/")}/`;
|
|
29
|
+
return `/${routePath}`;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const discoverStaticRoutes = (staticDir) => {
|
|
33
|
+
const root = path.resolve(staticDir);
|
|
34
|
+
|
|
35
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
36
|
+
throw new Error(`Static directory does not exist or is not a directory: ${root}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return htmlFiles(root)
|
|
40
|
+
.map((file) => ({
|
|
41
|
+
type: "static_html",
|
|
42
|
+
route: routeFor(root, file),
|
|
43
|
+
path: file,
|
|
44
|
+
}))
|
|
45
|
+
.sort((left, right) => ordinalCompare(left.route, right.route) || ordinalCompare(left.path, right.path));
|
|
46
|
+
};
|