11import type * as CommandExecutor from "@effect/platform/CommandExecutor"
22import type { PlatformError } from "@effect/platform/Error"
3- import { Effect } from "effect"
3+ import { Effect , pipe } from "effect"
44
5- import { resolveApiBaseUrl } from "./controller.js"
6- import { runCommandExitCodeStreaming } from "./frontend-lib/shell/command-runner.js"
5+ import { controllerExists } from "./controller-docker.js"
6+ import { type ControllerRuntime , ensureControllerReady , resolveApiBaseUrl , restartController } from "./controller.js"
7+ import {
8+ runCommandCapture ,
9+ runCommandExitCode ,
10+ runCommandExitCodeStreaming
11+ } from "./frontend-lib/shell/command-runner.js"
712import type { ControllerBootstrapError } from "./host-errors.js"
813
914const browserFrontendError = ( message : string ) : ControllerBootstrapError => ( {
@@ -25,6 +30,11 @@ const webHost = (): string => process.env["DOCKER_GIT_WEB_HOST"]?.trim() || "127
2530
2631const webPort = ( ) : string => process . env [ "DOCKER_GIT_WEB_PORT" ] ?. trim ( ) || "4174"
2732
33+ type BrowserFrontendRuntimeState = {
34+ readonly controllerRunning : boolean
35+ readonly webPids : ReadonlyArray < string >
36+ }
37+
2838const browserEnv = ( apiBaseUrl : string ) : Readonly < Record < string , string > > => ( {
2939 ...copyProcessEnv ( ) ,
3040 DOCKER_GIT_API_URL : apiBaseUrl ,
@@ -43,6 +53,146 @@ const runStreaming = (
4353 env
4454 } )
4555
56+ const parsePids = ( output : string ) : ReadonlyArray < string > =>
57+ output
58+ . split ( / \s + / u)
59+ . map ( ( pid ) => pid . trim ( ) )
60+ . filter ( ( pid ) => / ^ \d + $ / u. test ( pid ) )
61+
62+ const findWebServerPids = ( ) : Effect . Effect < ReadonlyArray < string > , never , CommandExecutor . CommandExecutor > => {
63+ const script = [
64+ "port=\"$1\"" ,
65+ "if command -v lsof >/dev/null 2>&1; then" ,
66+ " lsof -nP -tiTCP:\"$port\" -sTCP:LISTEN 2>/dev/null || true" ,
67+ " exit 0" ,
68+ "fi" ,
69+ "if command -v fuser >/dev/null 2>&1; then" ,
70+ String . raw ` fuser "$port/tcp" 2>/dev/null | tr ' ' '\n' || true` ,
71+ "fi"
72+ ] . join ( "\n" )
73+
74+ return runCommandCapture (
75+ {
76+ cwd : process . cwd ( ) ,
77+ command : "sh" ,
78+ args : [ "-c" , script , "sh" , webPort ( ) ]
79+ } ,
80+ [ 0 ] ,
81+ ( ) => browserFrontendError ( "Failed to inspect docker-git browser frontend port." )
82+ ) . pipe (
83+ Effect . map ( ( output ) => parsePids ( output ) ) ,
84+ Effect . orElseSucceed ( ( ) : ReadonlyArray < string > => [ ] )
85+ )
86+ }
87+
88+ const stopWebServerPids = (
89+ pids : ReadonlyArray < string >
90+ ) : Effect . Effect < void , ControllerBootstrapError | PlatformError , CommandExecutor . CommandExecutor > => {
91+ if ( pids . length === 0 ) {
92+ return Effect . void
93+ }
94+
95+ const script = [
96+ "kill \"$@\" 2>/dev/null || true" ,
97+ "sleep 1" ,
98+ "kill -9 \"$@\" 2>/dev/null || true"
99+ ] . join ( "\n" )
100+
101+ return runCommandExitCode ( {
102+ cwd : process . cwd ( ) ,
103+ command : "sh" ,
104+ args : [ "-c" , script , "sh" , ...pids ]
105+ } ) . pipe (
106+ Effect . flatMap ( ( exitCode ) =>
107+ exitCode === 0
108+ ? Effect . void
109+ : Effect . fail ( browserFrontendError ( `Failed to stop browser frontend pids: ${ pids . join ( ", " ) } ` ) )
110+ )
111+ )
112+ }
113+
114+ const readBrowserFrontendRuntimeState = ( ) : Effect . Effect <
115+ BrowserFrontendRuntimeState ,
116+ never ,
117+ ControllerRuntime
118+ > =>
119+ Effect . all ( {
120+ controllerRunning : controllerExists ( ) . pipe ( Effect . orElseSucceed ( ( ) => false ) ) ,
121+ webPids : findWebServerPids ( )
122+ } )
123+
124+ const renderRunningSummary = ( state : BrowserFrontendRuntimeState ) : string =>
125+ [
126+ state . controllerRunning ? "API controller is already running" : "" ,
127+ state . webPids . length > 0 ? `browser frontend is listening on port ${ webPort ( ) } ` : ""
128+ ] . filter ( ( line ) => line . length > 0 ) . join ( "; " )
129+
130+ const hasRunningBrowserStack = ( state : BrowserFrontendRuntimeState ) : boolean =>
131+ state . controllerRunning || state . webPids . length > 0
132+
133+ const normalizePromptAnswer = ( answer : string ) : boolean => {
134+ const normalized = answer . trim ( ) . toLowerCase ( )
135+ return normalized . length === 0 || normalized === "y" || normalized === "yes" || normalized === "д" ||
136+ normalized === "да"
137+ }
138+
139+ const promptRestart = ( state : BrowserFrontendRuntimeState ) : Effect . Effect < boolean > => {
140+ if ( ! process . stdin . isTTY || ! process . stdout . isTTY ) {
141+ return Effect . succeed ( true )
142+ }
143+
144+ return Effect . async ( ( resume ) => {
145+ const onData = ( chunk : Buffer ) => {
146+ process . stdin . off ( "data" , onData )
147+ resume ( Effect . succeed ( normalizePromptAnswer ( chunk . toString ( "utf8" ) ) ) )
148+ }
149+
150+ process . stdout . write ( `${ renderRunningSummary ( state ) } . Restart API and web frontend? [Y/n] ` )
151+ process . stdin . resume ( )
152+ process . stdin . once ( "data" , onData )
153+
154+ return Effect . sync ( ( ) => {
155+ process . stdin . off ( "data" , onData )
156+ } )
157+ } )
158+ }
159+
160+ const shouldRestartBrowserStack = (
161+ state : BrowserFrontendRuntimeState
162+ ) : Effect . Effect < boolean > => hasRunningBrowserStack ( state ) ? promptRestart ( state ) : Effect . succeed ( false )
163+
164+ const stopCurrentWebServer = ( ) : Effect . Effect <
165+ void ,
166+ ControllerBootstrapError | PlatformError ,
167+ CommandExecutor . CommandExecutor
168+ > =>
169+ pipe (
170+ findWebServerPids ( ) ,
171+ Effect . tap ( ( pids ) =>
172+ pids . length === 0 ? Effect . void : Effect . log ( `Stopping existing browser frontend pids: ${ pids . join ( ", " ) } ` )
173+ ) ,
174+ Effect . flatMap ( ( pids ) => stopWebServerPids ( pids ) )
175+ )
176+
177+ const prepareBrowserStack = ( ) : Effect . Effect <
178+ boolean ,
179+ ControllerBootstrapError | PlatformError ,
180+ ControllerRuntime
181+ > =>
182+ Effect . gen ( function * ( _ ) {
183+ const runtimeState = yield * _ ( readBrowserFrontendRuntimeState ( ) )
184+ const restart = yield * _ ( shouldRestartBrowserStack ( runtimeState ) )
185+ if ( ! restart ) {
186+ yield * _ ( ensureControllerReady ( ) )
187+ return runtimeState . webPids . length === 0
188+ }
189+
190+ yield * _ ( Effect . log ( "Restarting docker-git API controller." ) )
191+ yield * _ ( restartController ( ) )
192+ yield * _ ( stopCurrentWebServer ( ) )
193+ return true
194+ } )
195+
46196const ensureSuccess = (
47197 exitCode : number ,
48198 action : string
@@ -71,3 +221,26 @@ export const runBrowserFrontend: Effect.Effect<
71221 const serveExitCode = yield * _ ( runStreaming ( [ "run" , "--cwd" , "packages/app" , "serve:web" ] , env ) )
72222 yield * _ ( ensureSuccess ( serveExitCode , "Browser frontend server" ) )
73223} )
224+
225+ // CHANGE: make `docker-git browser` idempotent for local development
226+ // WHY: repeated invocations should deploy current controller code and replace the previous web process
227+ // QUOTE(ТЗ): "если её вызвать заново то перезапустит и web и api"
228+ // REF: user-request-2026-04-21-browser-restart
229+ // SOURCE: n/a
230+ // FORMAT THEOREM: ∀run: existing(api ∨ web) ∧ confirm(run) → restarted(api) ∧ restarted(web)
231+ // PURITY: SHELL
232+ // EFFECT: Effect<void, ControllerBootstrapError | PlatformError, CommandExecutor>
233+ // INVARIANT: a confirmed rerun force-recreates the controller before serving the new frontend
234+ // COMPLEXITY: O(processes + compose)
235+ export const runBrowserFrontendCommand : Effect . Effect <
236+ void ,
237+ ControllerBootstrapError | PlatformError ,
238+ ControllerRuntime
239+ > = pipe (
240+ prepareBrowserStack ( ) ,
241+ Effect . flatMap ( ( shouldStartWeb ) =>
242+ shouldStartWeb
243+ ? runBrowserFrontend
244+ : Effect . log ( `docker-git browser frontend is already running at http://${ webHost ( ) } :${ webPort ( ) } /` )
245+ )
246+ )
0 commit comments