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..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 PluginOption } 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) @@ -165,6 +165,215 @@ 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"] => { + if (optimizeDeps === undefined) { + return undefined + } + + const { esbuildOptions: _esbuildOptions, ...remainingOptions } = optimizeDeps + 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 => { + if (config === undefined || config === null) { + return config + } + + const { esbuild: _esbuild, optimizeDeps, ...remainingConfig } = config + const sanitizedOptimizeDeps = removeDeprecatedOptimizeDepsOptions(optimizeDeps) + return sanitizedOptimizeDeps === undefined + ? remainingConfig + : { + ...remainingConfig, + optimizeDeps: sanitizedOptimizeDeps + } +} + +/** + * 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" || 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 = gridlandConfig.handler + return { + ...plugin, + config: { + ...gridlandConfig, + handler(config, env) { + return sanitizeGridlandConfigResult(resolveGridlandConfig.call(this, config, env)) + } + } + } + }) + export default defineConfig(({ mode }) => { const env = loadEnv(mode, __dirname, "") const apiTarget = env["DOCKER_GIT_API_URL"]?.trim() || defaultApiTarget @@ -172,7 +381,7 @@ export default defineConfig(({ mode }) => { return { plugins: [ terminalWebSocketProxyPlugin(apiTarget), - ...gridlandWebPlugin(), + ...gridlandWebPluginWithoutDeprecatedOptions(), react() ], publicDir: false, @@ -211,14 +420,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..81209b8a --- /dev/null +++ b/scripts/ci/check-web-build-output.mjs @@ -0,0 +1,56 @@ +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: /(?:\[vite\]\s+warning:[^\n]*\bdeprecated\b|\(!\)[^\n]*\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" +}) + +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 = `${stdout}\n${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) +}