From 2cf2a43ada8045b9db4b12a243dddbb5835f0829 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 29 May 2026 10:25:23 +0000 Subject: [PATCH 1/2] fix(web): silence vite web build warnings --- .github/workflows/final-build.yml | 2 +- packages/app/package.json | 1 + packages/app/vite.web.config.ts | 69 +++++++++++++++++++++++---- scripts/ci/check-web-build-output.mjs | 50 +++++++++++++++++++ 4 files changed, 111 insertions(+), 11 deletions(-) create mode 100644 scripts/ci/check-web-build-output.mjs diff --git a/.github/workflows/final-build.yml b/.github/workflows/final-build.yml index ce24f93b..53b14306 100644 --- a/.github/workflows/final-build.yml +++ b/.github/workflows/final-build.yml @@ -32,7 +32,7 @@ jobs: run: bun ./packages/docker-git-session-sync/dist/docker-git-session-sync.js --help - name: Verify browser UI and menu clone smoke checks run: | - bun run --cwd packages/app build:web + bun run --cwd packages/app build:web:strict bun scripts/final-build/browser-web-smoke.mjs bun run --cwd packages/app vitest run tests/docker-git/browser-frontend.test.ts tests/docker-git/app-ready-create.test.ts tests/docker-git/actions-project-create.test.ts - name: Prepare package artifacts directory diff --git a/packages/app/package.json b/packages/app/package.json index 410b57c6..8a83eb9c 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -17,6 +17,7 @@ "build": "bun run build:app && bun run build:docker-git", "build:app": "vite build --ssr src/app/main.ts", "build:web": "vite build --config vite.web.config.ts", + "build:web:strict": "bun ../../scripts/ci/check-web-build-output.mjs", "prepack": "bun run build:docker-git", "dev": "vite build --watch --ssr src/app/main.ts", "dev:web": "vite --config vite.web.config.ts", diff --git a/packages/app/vite.web.config.ts b/packages/app/vite.web.config.ts index 88f34c05..1f08520f 100644 --- a/packages/app/vite.web.config.ts +++ b/packages/app/vite.web.config.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url" import { gridlandWebPlugin } from "@gridland/web/vite-plugin" import react from "@vitejs/plugin-react" -import { defineConfig, loadEnv, type PluginOption } from "vite" +import { defineConfig, loadEnv, type Plugin, type PluginOption, type UserConfig } from "vite" import { type RawData, WebSocket, WebSocketServer } from "ws" const __filename = fileURLToPath(import.meta.url) @@ -165,6 +165,57 @@ const terminalWebSocketProxyPlugin = (apiTarget: string): PluginOption => ({ } }) +type VitePluginConfig = Omit + +const removeDeprecatedOptimizeDepsOptions = ( + optimizeDeps: UserConfig["optimizeDeps"] +): UserConfig["optimizeDeps"] => { + if (optimizeDeps === undefined) { + return undefined + } + + const { esbuildOptions: _esbuildOptions, ...remainingOptions } = optimizeDeps + return remainingOptions +} + +const removeDeprecatedGridlandOptions = ( + config: VitePluginConfig | null | void +): VitePluginConfig | null | void => { + if (config === undefined || config === null) { + return config + } + + const { esbuild: _esbuild, optimizeDeps, ...remainingConfig } = config + const sanitizedOptimizeDeps = removeDeprecatedOptimizeDepsOptions(optimizeDeps) + return sanitizedOptimizeDeps === undefined + ? remainingConfig + : { + ...remainingConfig, + optimizeDeps: sanitizedOptimizeDeps + } +} + +const isVitePlugin = (plugin: PluginOption): plugin is Plugin => + typeof plugin === "object" && plugin !== null && !Array.isArray(plugin) && "name" in plugin + +const gridlandWebPluginWithoutDeprecatedOptions = (): ReadonlyArray => + gridlandWebPlugin().map((plugin) => { + if (!isVitePlugin(plugin) || plugin.name !== "gridland-web-aliases" || typeof plugin.config !== "function") { + return plugin + } + + const resolveGridlandConfig = plugin.config + return { + ...plugin, + config(config, env) { + const result = resolveGridlandConfig.call(this, config, env) + return result instanceof Promise + ? result.then(removeDeprecatedGridlandOptions) + : removeDeprecatedGridlandOptions(result) + } + } + }) + export default defineConfig(({ mode }) => { const env = loadEnv(mode, __dirname, "") const apiTarget = env["DOCKER_GIT_API_URL"]?.trim() || defaultApiTarget @@ -172,7 +223,7 @@ export default defineConfig(({ mode }) => { return { plugins: [ terminalWebSocketProxyPlugin(apiTarget), - ...gridlandWebPlugin(), + ...gridlandWebPluginWithoutDeprecatedOptions(), react() ], publicDir: false, @@ -211,14 +262,12 @@ export default defineConfig(({ mode }) => { build: { target: "esnext", outDir: "dist-web", - sourcemap: true - }, - esbuild: { - target: "esnext" - }, - optimizeDeps: { - esbuildOptions: { - target: "esnext" + sourcemap: true, + chunkSizeWarningLimit: 1200, + rolldownOptions: { + checks: { + invalidAnnotation: false + } } } } diff --git a/scripts/ci/check-web-build-output.mjs b/scripts/ci/check-web-build-output.mjs new file mode 100644 index 00000000..0e85a3c1 --- /dev/null +++ b/scripts/ci/check-web-build-output.mjs @@ -0,0 +1,50 @@ +import { spawnSync } from "node:child_process" +import { fileURLToPath } from "node:url" + +const repoRoot = fileURLToPath(new URL("../..", import.meta.url)) +const runtime = process.versions.bun === undefined ? "bun" : process.execPath +const forbiddenOutput = [ + { + label: "Vite warning", + pattern: /\[vite\]\s+warning:/iu + }, + { + label: "Rolldown invalid annotation warning", + pattern: /\[INVALID_ANNOTATION\]/u + }, + { + label: "Deprecated build option warning", + pattern: /\bdeprecated\b/iu + }, + { + label: "Chunk size warning", + pattern: /Some chunks are larger than/u + } +] + +const result = spawnSync(runtime, ["run", "--cwd", "packages/app", "build:web"], { + cwd: repoRoot, + encoding: "utf8" +}) + +process.stdout.write(result.stdout) +process.stderr.write(result.stderr) + +if (result.error !== undefined) { + console.error(result.error) + process.exit(1) +} + +if (result.status !== 0) { + process.exit(result.status ?? 1) +} + +const output = `${result.stdout}\n${result.stderr}` +const matches = forbiddenOutput.filter(({ pattern }) => pattern.test(output)) +if (matches.length > 0) { + console.error("Web build emitted forbidden warning output:") + for (const match of matches) { + console.error(`- ${match.label}`) + } + process.exit(1) +} From f6f8b4d080b167598578945cbba1cd52e700075f Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 29 May 2026 10:45:55 +0000 Subject: [PATCH 2/2] fix(web): address strict build review comments --- packages/app/vite.web.config.ts | 174 ++++++++++++++++++++++++-- scripts/ci/check-web-build-output.mjs | 16 ++- 2 files changed, 177 insertions(+), 13 deletions(-) diff --git a/packages/app/vite.web.config.ts b/packages/app/vite.web.config.ts index 1f08520f..5b12f5fd 100644 --- a/packages/app/vite.web.config.ts +++ b/packages/app/vite.web.config.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url" import { gridlandWebPlugin } from "@gridland/web/vite-plugin" import react from "@vitejs/plugin-react" -import { defineConfig, loadEnv, type Plugin, type PluginOption, type UserConfig } from "vite" +import { defineConfig, loadEnv, type HookHandler, type Plugin, type PluginOption, type UserConfig } from "vite" import { type RawData, WebSocket, WebSocketServer } from "ws" const __filename = fileURLToPath(import.meta.url) @@ -166,7 +166,32 @@ const terminalWebSocketProxyPlugin = (apiTarget: string): PluginOption => ({ }) type VitePluginConfig = Omit +type ViteConfigHook = HookHandler> +type ViteConfigObjectHook = Exclude, ViteConfigHook> +type ViteConfigHookResult = ReturnType +/** + * Removes the deprecated dependency optimizer esbuild bridge from optional Vite optimizeDeps config. + * + * @param optimizeDeps - Optional Vite dependency optimizer config emitted by a plugin. + * @returns `undefined` when no config exists; otherwise a shallow copy without `esbuildOptions`. + * @pure true + * @precondition `optimizeDeps` is either undefined or a Vite optimizeDeps object. + * @postcondition The result is undefined iff the input is undefined; otherwise `esbuildOptions` is absent. + * @invariant Every non-`esbuildOptions` own field is preserved by key and value. + * @complexity O(k) time / O(k) space, where k is the number of own optimizeDeps fields. + * @throws Never. + */ +// CHANGE: Strip only the deprecated optimizeDeps.esbuildOptions field. +// WHY: Vite 8 warns on that bridge while all other optimizer settings remain valid input. +// QUOTE(ТЗ): "Что бы оно не писалось" +// REF: PR #356 review 4388758572 +// SOURCE: https://github.com/ProverCoderAI/docker-git/pull/356#pullrequestreview-4388758572 +// FORMAT THEOREM: ∀o ∈ OptimizeDeps: strip(o) = o \ {esbuildOptions} +// PURITY: CORE +// EFFECT: none +// INVARIANT: ∀key ≠ esbuildOptions: result[key] = optimizeDeps[key] +// COMPLEXITY: O(k) time / O(k) space const removeDeprecatedOptimizeDepsOptions = ( optimizeDeps: UserConfig["optimizeDeps"] ): UserConfig["optimizeDeps"] => { @@ -178,6 +203,28 @@ const removeDeprecatedOptimizeDepsOptions = ( return remainingOptions } +/** + * Removes deprecated top-level and nested Gridland Vite config options from an optional plugin config. + * + * @param config - Optional Vite config fragment returned by the Gridland aliases plugin. + * @returns The original nullish value, or a shallow config copy without deprecated esbuild fields. + * @pure true + * @precondition `config` is nullish or a Vite plugin config fragment without a `plugins` field. + * @postcondition Returned object has no top-level `esbuild`; nested `optimizeDeps.esbuildOptions` is absent. + * @invariant All non-deprecated config fields are preserved by key and value. + * @complexity O(k + n) time / O(k + n) space, where k is config fields and n is optimizeDeps fields. + * @throws Never. + */ +// CHANGE: Normalize Gridland config fragments before Vite consumes them. +// WHY: The deprecated fields are warning-only compatibility options, not required for web build semantics. +// QUOTE(ТЗ): "Что бы оно не писалось" +// REF: PR #356 review 4388758572 +// SOURCE: https://github.com/ProverCoderAI/docker-git/pull/356#pullrequestreview-4388758572 +// FORMAT THEOREM: ∀c ∈ Config: normalize(c).esbuild = undefined ∧ normalize(c).optimizeDeps.esbuildOptions = undefined +// PURITY: CORE +// EFFECT: none +// INVARIANT: ∀key ∉ {esbuild,optimizeDeps.esbuildOptions}: normalize(c)[key] = c[key] +// COMPLEXITY: O(k + n) time / O(k + n) space const removeDeprecatedGridlandOptions = ( config: VitePluginConfig | null | void ): VitePluginConfig | null | void => { @@ -195,23 +242,134 @@ const removeDeprecatedGridlandOptions = ( } } +/** + * Tests whether a Vite plugin option is a concrete plugin object with a name. + * + * @param plugin - Vite plugin option produced by a plugin factory. + * @returns True when the option is an object plugin; false for arrays, null, booleans, and functions. + * @pure true + * @precondition `plugin` is any value accepted by Vite as PluginOption. + * @postcondition A true result narrows `plugin` to `Plugin` for property-safe access. + * @invariant The predicate has no side effects and does not mutate the inspected value. + * @complexity O(1) time / O(1) space. + * @throws Never. + */ +// CHANGE: Provide a pure predicate for concrete Vite plugin objects. +// WHY: The wrapper must only inspect named object plugins and preserve every other plugin option shape. +// QUOTE(ТЗ): "Что бы оно не писалось" +// REF: PR #356 review 4388758572 +// SOURCE: https://github.com/ProverCoderAI/docker-git/pull/356#pullrequestreview-4388758572 +// FORMAT THEOREM: ∀p ∈ PluginOption: isVitePlugin(p) → "name" ∈ keys(p) +// PURITY: CORE +// EFFECT: none +// INVARIANT: isVitePlugin(p) is a deterministic boolean predicate over p's runtime shape. +// COMPLEXITY: O(1) time / O(1) space const isVitePlugin = (plugin: PluginOption): plugin is Plugin => typeof plugin === "object" && plugin !== null && !Array.isArray(plugin) && "name" in plugin +/** + * Tests whether a Vite config hook is declared in object-hook form. + * + * @param hook - Concrete Vite config hook from a plugin. + * @returns True when the hook has a callable `handler` property. + * @pure true + * @precondition `hook` is a non-null Vite config hook. + * @postcondition A true result narrows `hook` to object-hook form. + * @invariant The predicate does not call or mutate the hook. + * @complexity O(1) time / O(1) space. + * @throws Never. + */ +// CHANGE: Recognize Vite object-hook config declarations. +// WHY: Sanitization should be stable if Gridland changes from function hook to object hook. +// QUOTE(ТЗ): "Что бы оно не писалось" +// REF: PR #356 review 4388758572 +// SOURCE: https://github.com/ProverCoderAI/docker-git/pull/356#pullrequestreview-4388758572 +// FORMAT THEOREM: ∀h ∈ ConfigHook: isObjectHook(h) → callable(h.handler) +// PURITY: CORE +// EFFECT: none +// INVARIANT: isViteConfigObjectHook(h) is deterministic over h's runtime shape. +// COMPLEXITY: O(1) time / O(1) space +const isViteConfigObjectHook = ( + hook: NonNullable +): hook is ViteConfigObjectHook => + typeof hook === "object" && hook !== null && "handler" in hook && typeof hook.handler === "function" + +/** + * Sanitizes either synchronous or asynchronous Gridland config hook output. + * + * @param result - Result returned by the original Gridland aliases config hook. + * @returns The same sync/async shape with deprecated options removed from the resolved config. + * @pure true + * @precondition `result` is a valid Vite config hook result. + * @postcondition Nullish results remain nullish; config objects are normalized after resolution. + * @invariant Promise shape is preserved: Promise input yields Promise output; sync input yields sync output. + * @complexity O(k + n) time / O(k + n) space after the hook result resolves. + * @throws Never. + */ +// CHANGE: Centralize sync and async Gridland config result normalization. +// WHY: Function and object Vite hooks must share the same warning-suppression invariant. +// QUOTE(ТЗ): "Что бы оно не писалось" +// REF: PR #356 review 4388758572 +// SOURCE: https://github.com/ProverCoderAI/docker-git/pull/356#pullrequestreview-4388758572 +// FORMAT THEOREM: ∀r ∈ HookResult: sanitize(r) resolves to normalize(r) +// PURITY: CORE +// EFFECT: none +// INVARIANT: Sync/async result shape is preserved while resolved config is normalized. +// COMPLEXITY: O(k + n) time / O(k + n) space +const sanitizeGridlandConfigResult = (result: ViteConfigHookResult): ViteConfigHookResult => + result instanceof Promise + ? result.then(removeDeprecatedGridlandOptions) + : removeDeprecatedGridlandOptions(result) + +/** + * Produces Gridland web plugins with the aliases config hook wrapped to suppress deprecated Vite output. + * + * @returns Plugin options from `gridlandWebPlugin` with only `gridland-web-aliases` config sanitized. + * @pure true + * @precondition `gridlandWebPlugin` returns Vite plugin options. + * @postcondition Non-object plugins and non-target plugins are preserved; target config output is normalized. + * @invariant Plugin order and non-target plugin identity are preserved. + * @complexity O(p) time / O(p) space, where p is the number of Gridland plugin options. + * @throws Never. + */ +// CHANGE: Wrap only the Gridland aliases plugin config hook. +// WHY: The build warning source is localized to that plugin; unrelated plugins must retain their behavior. +// QUOTE(ТЗ): "Что бы оно не писалось" +// REF: PR #356 review 4388758572 +// SOURCE: https://github.com/ProverCoderAI/docker-git/pull/356#pullrequestreview-4388758572 +// FORMAT THEOREM: ∀p ≠ aliases: wrap(p) = p; aliases config output is normalized. +// PURITY: CORE +// EFFECT: none +// INVARIANT: Plugin array length and order are unchanged. +// COMPLEXITY: O(p) time / O(p) space const gridlandWebPluginWithoutDeprecatedOptions = (): ReadonlyArray => gridlandWebPlugin().map((plugin) => { - if (!isVitePlugin(plugin) || plugin.name !== "gridland-web-aliases" || typeof plugin.config !== "function") { + if (!isVitePlugin(plugin) || plugin.name !== "gridland-web-aliases" || plugin.config === undefined) { + return plugin + } + + const gridlandConfig = plugin.config + if (typeof gridlandConfig === "function") { + return { + ...plugin, + config(config, env) { + return sanitizeGridlandConfigResult(gridlandConfig.call(this, config, env)) + } + } + } + + if (!isViteConfigObjectHook(gridlandConfig)) { return plugin } - const resolveGridlandConfig = plugin.config + const resolveGridlandConfig = gridlandConfig.handler return { ...plugin, - config(config, env) { - const result = resolveGridlandConfig.call(this, config, env) - return result instanceof Promise - ? result.then(removeDeprecatedGridlandOptions) - : removeDeprecatedGridlandOptions(result) + config: { + ...gridlandConfig, + handler(config, env) { + return sanitizeGridlandConfigResult(resolveGridlandConfig.call(this, config, env)) + } } } }) diff --git a/scripts/ci/check-web-build-output.mjs b/scripts/ci/check-web-build-output.mjs index 0e85a3c1..81209b8a 100644 --- a/scripts/ci/check-web-build-output.mjs +++ b/scripts/ci/check-web-build-output.mjs @@ -14,7 +14,7 @@ const forbiddenOutput = [ }, { label: "Deprecated build option warning", - pattern: /\bdeprecated\b/iu + pattern: /(?:\[vite\]\s+warning:[^\n]*\bdeprecated\b|\(!\)[^\n]*\bdeprecated\b)/iu }, { label: "Chunk size warning", @@ -27,19 +27,25 @@ const result = spawnSync(runtime, ["run", "--cwd", "packages/app", "build:web"], encoding: "utf8" }) -process.stdout.write(result.stdout) -process.stderr.write(result.stderr) - if (result.error !== undefined) { console.error(result.error) process.exit(1) } +const stdout = result.stdout ?? "" +const stderr = result.stderr ?? "" +if (stdout.length > 0) { + process.stdout.write(stdout) +} +if (stderr.length > 0) { + process.stderr.write(stderr) +} + if (result.status !== 0) { process.exit(result.status ?? 1) } -const output = `${result.stdout}\n${result.stderr}` +const output = `${stdout}\n${stderr}` const matches = forbiddenOutput.filter(({ pattern }) => pattern.test(output)) if (matches.length > 0) { console.error("Web build emitted forbidden warning output:")