From dec46f3ee4e9779766d3c825fec8c85aad2b3035 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sat, 8 Aug 2026 22:38:42 +0200 Subject: [PATCH] fix(ci): detect custom widget runtime chunks --- scripts/check-custom-widget-bundle.mjs | 39 ++++++++++++++++++---- scripts/check-custom-widget-bundle.spec.ts | 36 ++++++++++++++++++++ 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/scripts/check-custom-widget-bundle.mjs b/scripts/check-custom-widget-bundle.mjs index 4b6d6b352..f4a6bd5ee 100644 --- a/scripts/check-custom-widget-bundle.mjs +++ b/scripts/check-custom-widget-bundle.mjs @@ -3,7 +3,7 @@ import { readdir, readFile, stat } from "node:fs/promises"; import { basename, join, relative, resolve } from "node:path"; const repositoryRoot = resolve(import.meta.dirname, ".."); -const nextRoot = join(repositoryRoot, "apps/nextjs/.next"); +const nextRoot = resolve(process.env.CUSTOM_WIDGET_NEXT_ROOT ?? join(repositoryRoot, "apps/nextjs/.next")); const budgetBytes = Number(process.env.CUSTOM_WIDGET_CHUNK_BUDGET_BYTES ?? 768 * 1024); if (!Number.isSafeInteger(budgetBytes) || budgetBytes <= 0) { throw new Error("CUSTOM_WIDGET_CHUNK_BUDGET_BYTES must be a positive integer."); @@ -32,6 +32,15 @@ async function resolveChunk(chunk) { return null; } +function isRuntimeOrWorkbenchRoute(manifestPath) { + const normalizedManifestPath = manifestPath.replaceAll("\\", "/"); + return ( + normalizedManifestPath.includes("/(board)/") || + normalizedManifestPath.includes("/boards/(content)/(home)/") || + normalizedManifestPath.includes("/manage/custom-widgets/") + ); +} + let serverChunks; let manifestPaths; try { @@ -50,18 +59,34 @@ for (const serverChunk of serverChunks) { const source = await readFile(serverChunk, "utf8"); for (const match of source.matchAll(/loadableGenerated:\{modules:\[(\d+)\]\}/gu)) moduleIds.add(match[1]); } +if (moduleIds.size === 0) { + const hasRuntimeMarker = new Map(); + for (const manifestPath of manifestPaths) { + if (!isRuntimeOrWorkbenchRoute(manifestPath)) continue; + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + for (const [moduleId, entry] of Object.entries(manifest)) { + for (const file of entry.files ?? []) { + if (!file.endsWith(".js")) continue; + let hasMarker = hasRuntimeMarker.get(file); + if (hasMarker === undefined) { + const chunkPath = await resolveChunk(file); + hasMarker = chunkPath ? (await readFile(chunkPath, "utf8")).includes("RUNTIME_RENDER_ERROR") : false; + hasRuntimeMarker.set(file, hasMarker); + } + if (!hasMarker) continue; + moduleIds.add(moduleId); + break; + } + } + } +} if (moduleIds.size === 0) { throw new Error("The Custom JSX dynamic module was not found in the Next.js server build."); } const routePayloads = []; for (const manifestPath of manifestPaths) { - const normalizedManifestPath = manifestPath.replaceAll("\\", "/"); - const isRuntimeOrWorkbenchRoute = - normalizedManifestPath.includes("/(board)/") || - normalizedManifestPath.includes("/boards/(content)/(home)/") || - normalizedManifestPath.includes("/manage/custom-widgets/"); - if (!isRuntimeOrWorkbenchRoute) continue; + if (!isRuntimeOrWorkbenchRoute(manifestPath)) continue; const manifest = JSON.parse(await readFile(manifestPath, "utf8")); const files = new Set(); for (const moduleId of moduleIds) { diff --git a/scripts/check-custom-widget-bundle.spec.ts b/scripts/check-custom-widget-bundle.spec.ts index fcb41db55..7b5412158 100644 --- a/scripts/check-custom-widget-bundle.spec.ts +++ b/scripts/check-custom-widget-bundle.spec.ts @@ -1,6 +1,9 @@ // @vitest-environment node import { spawnSync } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import { describe, expect, test } from "vitest"; describe("Custom Widget bundle budget", () => { @@ -18,4 +21,37 @@ describe("Custom Widget bundle budget", () => { expect(result.stderr).not.toContain("Next.js build output is missing"); }, ); + + test("finds the dynamic module from its runtime chunk when server metadata is absent", async () => { + const nextRoot = await mkdtemp(join(tmpdir(), "homarr-custom-widget-bundle-")); + const serverChunk = join(nextRoot, "server/chunks/ssr/packages_widgets_src_custom-api_component_test.js"); + const manifestPath = join( + nextRoot, + "standalone/apps/nextjs/.next/server/app/[locale]/(home)/(board)/page/react-loadable-manifest.json", + ); + const runtimeChunk = join(nextRoot, "static/chunks/custom-widget-runtime.js"); + try { + await Promise.all([ + mkdir(dirname(serverChunk), { recursive: true }), + mkdir(dirname(manifestPath), { recursive: true }), + mkdir(dirname(runtimeChunk), { recursive: true }), + ]); + await Promise.all([ + writeFile(serverChunk, "const generatedWithoutLoadableMetadata = true;"), + writeFile(runtimeChunk, 'const runtimeError = "RUNTIME_RENDER_ERROR";'), + writeFile(manifestPath, JSON.stringify({ 42: { id: 42, files: ["static/chunks/custom-widget-runtime.js"] } })), + ]); + + const result = spawnSync(process.execPath, ["scripts/check-custom-widget-bundle.mjs"], { + cwd: process.cwd(), + encoding: "utf8", + env: { ...process.env, CUSTOM_WIDGET_NEXT_ROOT: nextRoot }, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Custom JSX bundle check passed"); + } finally { + await rm(nextRoot, { force: true, recursive: true }); + } + }); });