P1-0: V8 heap cap (HOMARR_MAX_OLD_SPACE_SIZE, default 512MB)
P1-1: Module graph cuts
- Dynamic integration imports (55 static → lazy loaders)
- Conditional DB driver loading (only configured driver loaded)
- Replace jsdom with linkedom for SVG sanitization
- Delete eager widget barrel (55 import * as → 0)
- Extract createRetryableLoader to @homarr/common
P1-2: Enable cacheComponents + restructure root layout
- Suspense boundaries around session-dependent providers
- ColorSchemeScript replaces server-side cookie reads
- Board/manage/auth layouts wrapped in Suspense
P1-3: Cache read paths
- Cache tag registry + invalidation helpers
- Cache invalidation wired into all mutation routers
- Translation deep-merge memoized per locale
P1-4: Prerendered shells + loading.tsx files
P1-5: Shared integration data cache (60s TTL, stale-while-revalidate)
P1-6: Redis-backed Next.js cache handler (@homarr/next-cache-handler)
P1-7: Redis optional (memory channel fallback), TTL on pubSub:last:*,
Redis clients consolidated from 6 to 2
234 lines
9.5 KiB
TypeScript
234 lines
9.5 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { parse } from "@babel/parser";
|
|
|
|
export const getRuntimeModuleSpecifiers = (sourceText: string, fileName: string) => {
|
|
try {
|
|
const ast = parse(sourceText, { sourceFilename: fileName, sourceType: "module", plugins: ["typescript", "jsx"] });
|
|
return ast.program.body.flatMap((statement) => {
|
|
if (statement.type === "ImportDeclaration") {
|
|
if (statement.importKind === "type" || statement.importKind === "typeof") return [];
|
|
const hasRuntimeBinding =
|
|
statement.specifiers.length === 0 ||
|
|
statement.specifiers.some(
|
|
(specifier) => specifier.type !== "ImportSpecifier" || specifier.importKind !== "type",
|
|
);
|
|
return hasRuntimeBinding ? [statement.source.value] : [];
|
|
}
|
|
if (statement.type === "ExportAllDeclaration") {
|
|
return statement.exportKind === "type" ? [] : [statement.source.value];
|
|
}
|
|
if (statement.type === "ExportNamedDeclaration" && statement.source) {
|
|
if (statement.exportKind === "type") return [];
|
|
const hasRuntimeBinding =
|
|
statement.specifiers.length === 0 ||
|
|
statement.specifiers.some((specifier) => specifier.exportKind !== "type");
|
|
return hasRuntimeBinding ? [statement.source.value] : [];
|
|
}
|
|
return [];
|
|
});
|
|
} catch (error) {
|
|
throw new Error(`Unable to parse static imports in ${fileName}`, { cause: error });
|
|
}
|
|
};
|
|
|
|
const canonicalize = (fileName: string) => {
|
|
try {
|
|
return fs.realpathSync.native(fileName);
|
|
} catch {
|
|
return path.resolve(fileName);
|
|
}
|
|
};
|
|
|
|
const resolveFile = (candidate: string) => {
|
|
const candidates = path.extname(candidate)
|
|
? [candidate]
|
|
: [
|
|
candidate,
|
|
`${candidate}.ts`,
|
|
`${candidate}.tsx`,
|
|
`${candidate}.mts`,
|
|
`${candidate}.js`,
|
|
`${candidate}.jsx`,
|
|
path.join(candidate, "index.ts"),
|
|
path.join(candidate, "index.tsx"),
|
|
path.join(candidate, "index.mts"),
|
|
path.join(candidate, "index.js"),
|
|
];
|
|
return candidates.find((fileName) => fs.existsSync(fileName) && fs.statSync(fileName).isFile()) ?? null;
|
|
};
|
|
|
|
const firstExportTarget = (value: unknown): string | null => {
|
|
if (typeof value === "string") return value;
|
|
if (!value || typeof value !== "object") return null;
|
|
for (const nested of Object.values(value)) {
|
|
const target = firstExportTarget(nested);
|
|
if (target) return target;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const getPackageName = (specifier: string) => specifier.split("/").slice(0, 2).join("/");
|
|
|
|
const resolvePackageExport = (exports: unknown, exportKey: string) => {
|
|
if (exportKey === "." && typeof exports === "string") return exports;
|
|
if (!exports || typeof exports !== "object") return null;
|
|
const exportMap = exports as Record<string, unknown>;
|
|
const exact = firstExportTarget(exportMap[exportKey]);
|
|
if (exact) return exact;
|
|
|
|
for (const [pattern, value] of Object.entries(exportMap)) {
|
|
const wildcard = pattern.indexOf("*");
|
|
if (wildcard < 0) continue;
|
|
const prefix = pattern.slice(0, wildcard);
|
|
const suffix = pattern.slice(wildcard + 1);
|
|
if (!exportKey.startsWith(prefix) || !exportKey.endsWith(suffix)) continue;
|
|
const match = exportKey.slice(prefix.length, exportKey.length - suffix.length);
|
|
const target = firstExportTarget(value);
|
|
if (target) return target.replaceAll("*", match);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const getWorkspacePackages = (root: string) => {
|
|
const packages = new Map<string, { directory: string; exports: unknown }>();
|
|
for (const parent of ["apps", "packages", "tooling"]) {
|
|
const parentDirectory = path.join(root, parent);
|
|
if (!fs.existsSync(parentDirectory)) continue;
|
|
for (const directoryEntry of fs.readdirSync(parentDirectory, { withFileTypes: true })) {
|
|
if (!directoryEntry.isDirectory()) continue;
|
|
const directory = path.join(parentDirectory, directoryEntry.name);
|
|
const manifestPath = path.join(directory, "package.json");
|
|
if (!fs.existsSync(manifestPath)) continue;
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { name?: string; exports?: unknown };
|
|
if (manifest.name) packages.set(manifest.name, { directory, exports: manifest.exports });
|
|
}
|
|
}
|
|
return packages;
|
|
};
|
|
|
|
export const collectBoardStaticGraph = (repositoryRoot: string) => {
|
|
const root = canonicalize(repositoryRoot);
|
|
const entries = [
|
|
"apps/nextjs/src/app/[locale]/layout.tsx",
|
|
"apps/nextjs/src/app/[locale]/(home)/(board)/layout.tsx",
|
|
"apps/nextjs/src/app/[locale]/(home)/(board)/page.tsx",
|
|
"apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/layout.tsx",
|
|
"apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/page.tsx",
|
|
"apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx",
|
|
].map((entry) => path.join(root, entry));
|
|
const workspacePackages = getWorkspacePackages(root);
|
|
const unresolvedWorkspaceImports: Array<{ importer: string; specifier: string }> = [];
|
|
|
|
const resolveSpecifier = (specifier: string, importer: string) => {
|
|
if (specifier.startsWith(".")) return resolveFile(path.resolve(path.dirname(importer), specifier));
|
|
if (specifier.startsWith("~/")) return resolveFile(path.join(root, "apps/nextjs/src", specifier.slice(2)));
|
|
if (specifier.startsWith("@static-data/")) {
|
|
return resolveFile(path.join(root, "static-data", specifier.slice("@static-data/".length)));
|
|
}
|
|
if (!specifier.startsWith("@homarr/") || specifier === "@homarr/node-unifi") return null;
|
|
|
|
const [, , ...subpathParts] = specifier.split("/");
|
|
const workspacePackage = workspacePackages.get(getPackageName(specifier));
|
|
if (!workspacePackage) return null;
|
|
const exportKey = subpathParts.length === 0 ? "." : `./${subpathParts.join("/")}`;
|
|
const target = resolvePackageExport(workspacePackage.exports, exportKey);
|
|
return target ? resolveFile(path.resolve(workspacePackage.directory, target)) : null;
|
|
};
|
|
|
|
const queue = [...entries];
|
|
const visited = new Set<string>();
|
|
const parents = new Map<string, { importer: string; specifier: string }>();
|
|
const bareWidgetImports: Array<{ importer: string; specifier: string }> = [];
|
|
const forbiddenModules = new Set([
|
|
path.join(root, "packages/spotlight/src/component.ts"),
|
|
path.join(root, "packages/spotlight/src/components/spotlight.tsx"),
|
|
path.join(root, "packages/spotlight/src/spotlight-store.ts"),
|
|
path.join(root, "packages/widgets/src/modals/widget-edit-modal.tsx"),
|
|
path.join(root, "apps/nextjs/src/components/board/items/item-move-modal.tsx"),
|
|
path.join(root, "apps/nextjs/src/components/onboarding/board-tour.tsx"),
|
|
path.join(root, "apps/nextjs/src/components/onboarding/tour-shell.tsx"),
|
|
]);
|
|
|
|
while (queue.length > 0) {
|
|
const next = queue.shift();
|
|
if (!next) continue;
|
|
const current = canonicalize(next);
|
|
if (visited.has(current)) continue;
|
|
visited.add(current);
|
|
if (!/\.[cm]?[jt]sx?$/.test(current)) continue;
|
|
|
|
const sourceText = fs.readFileSync(current, "utf8");
|
|
for (const specifier of getRuntimeModuleSpecifiers(sourceText, current)) {
|
|
if (specifier === "@homarr/widgets") bareWidgetImports.push({ importer: current, specifier });
|
|
const resolved = resolveSpecifier(specifier, current);
|
|
if (!resolved) {
|
|
if (workspacePackages.has(getPackageName(specifier))) {
|
|
unresolvedWorkspaceImports.push({ importer: current, specifier });
|
|
}
|
|
continue;
|
|
}
|
|
const target = canonicalize(resolved);
|
|
if (!target.startsWith(`${root}${path.sep}`) || target.includes(`${path.sep}node_modules${path.sep}`)) continue;
|
|
if (!parents.has(target)) parents.set(target, { importer: current, specifier });
|
|
queue.push(target);
|
|
}
|
|
}
|
|
|
|
const reachedForbiddenModules = [...forbiddenModules].filter((fileName) => visited.has(fileName));
|
|
const formatChain = (target: string) => {
|
|
const chain = [path.relative(root, target)];
|
|
let current = target;
|
|
while (parents.has(current)) {
|
|
const parent = parents.get(current);
|
|
if (!parent) break;
|
|
chain.unshift(`${path.relative(root, parent.importer)} --${parent.specifier}-->`);
|
|
current = parent.importer;
|
|
}
|
|
return chain.join(" ");
|
|
};
|
|
|
|
return {
|
|
entries: entries.map((entry) => path.relative(root, entry)),
|
|
visitedModuleCount: visited.size,
|
|
bareWidgetImports: bareWidgetImports.map(({ importer, specifier }) => ({
|
|
importer: path.relative(root, importer),
|
|
specifier,
|
|
})),
|
|
reachedForbiddenModules: reachedForbiddenModules.map(formatChain),
|
|
unresolvedWorkspaceImports: unresolvedWorkspaceImports.map(({ importer, specifier }) => ({
|
|
importer: path.relative(root, importer),
|
|
specifier,
|
|
})),
|
|
};
|
|
};
|
|
|
|
const run = () => {
|
|
const repositoryRoot = path.resolve(process.env.BOARD_GRAPH_ROOT ?? ".");
|
|
const result = collectBoardStaticGraph(repositoryRoot);
|
|
if (
|
|
result.bareWidgetImports.length > 0 ||
|
|
result.reachedForbiddenModules.length > 0 ||
|
|
result.unresolvedWorkspaceImports.length > 0
|
|
) {
|
|
throw new Error(`Board static graph reaches a forbidden eager module:\n${JSON.stringify(result, null, 2)}`);
|
|
}
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
...result,
|
|
eagerWidgetRegistryReachable: false,
|
|
eagerSpotlightImplementationReachable: false,
|
|
eagerWidgetEditorReachable: false,
|
|
eagerItemMoveEditorReachable: false,
|
|
eagerBoardTourReachable: false,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
};
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) run();
|