Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/final-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
227 changes: 217 additions & 10 deletions packages/app/vite.web.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -165,14 +165,223 @@ const terminalWebSocketProxyPlugin = (apiTarget: string): PluginOption => ({
}
})

type VitePluginConfig = Omit<UserConfig, "plugins">
type ViteConfigHook = HookHandler<NonNullable<Plugin["config"]>>
type ViteConfigObjectHook = Exclude<NonNullable<Plugin["config"]>, ViteConfigHook>
type ViteConfigHookResult = ReturnType<ViteConfigHook>

/**
* 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<Plugin["config"]>
): 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<PluginOption> =>
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))
}
}
}
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export default defineConfig(({ mode }) => {
const env = loadEnv(mode, __dirname, "")
const apiTarget = env["DOCKER_GIT_API_URL"]?.trim() || defaultApiTarget

return {
plugins: [
terminalWebSocketProxyPlugin(apiTarget),
...gridlandWebPlugin(),
...gridlandWebPluginWithoutDeprecatedOptions(),
react()
],
publicDir: false,
Expand Down Expand Up @@ -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
}
}
}
}
Expand Down
56 changes: 56 additions & 0 deletions scripts/ci/check-web-build-output.mjs
Original file line number Diff line number Diff line change
@@ -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
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{
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)
}
Loading