new apps/switch (beta)

This commit is contained in:
Gerasimos (Makis) Maropoulos 2020-08-17 21:53:17 +03:00
parent a61f743fa8
commit 589c8c6242
No known key found for this signature in database
GPG Key ID: 5DBE766BD26A54E7
14 changed files with 678 additions and 48 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
.vscode
.directory
coverage.out
package-lock.json
go.sum
node_modules

View File

@ -5,6 +5,8 @@ os:
- linux
- osx
go:
# We support the latest two major Go versions:
# https://golang.org/doc/devel/release.html
- 1.14.x
- 1.15.x
# - master
@ -12,6 +14,15 @@ go_import_path: github.com/kataras/iris/v12
env:
global:
- GO111MODULE=on
addons:
hosts:
- mydomain.com
- www.mydomain.com
- myotherdomain.com
- mymy.com
- testdomain.com
- testdomain1.com
- testdomain2.com
install:
- go get ./...
script:

4
apps/apps.go Normal file
View File

@ -0,0 +1,4 @@
// Package apps is responsible to control many Iris Applications.
// This package directly imports the iris root package and cannot be used
// inside Iris' codebase itself. Only external packages/programs can make use of it.
package apps

127
apps/switch.go Normal file
View File

@ -0,0 +1,127 @@
package apps
import (
"github.com/kataras/iris/v12"
)
// Switch returns a new Application
// with the sole purpose of routing the
// matched Applications through the provided "cases".
//
// The cases are filtered in order of register.
//
// Example:
// switcher := Switch(Hosts{
// "mydomain.com": app,
// "test.mydomain.com": testSubdomainApp,
// "otherdomain.com": "appName",
// })
// switcher.Listen(":80")
//
// Note that this is NOT a load balancer. The filters
// are executed by registration order and matched Application
// handles the request.
//
// The returned Switch Application can register routes that will run
// if no application is matched against the given filters.
// The returned Switch Application can also register custom error code handlers,
// e.g. to inject the 404 on not application found.
// It can also be wrapped with its `WrapRouter` method,
// which is really useful for logging and statistics.
func Switch(providers ...SwitchProvider) *iris.Application {
if len(providers) == 0 {
panic("iris: switch: empty providers")
}
var cases []SwitchCase
for _, p := range providers {
for _, c := range p.GetSwitchCases() {
cases = append(cases, c)
}
}
if len(cases) == 0 {
panic("iris: switch: empty cases")
}
app := iris.New()
// Try to build the cases apps on app.Build/Listen/Run so
// end-developers don't worry about it.
app.OnBuild = func() error {
for _, c := range cases {
if err := c.App.Build(); err != nil {
return err
}
}
return nil
}
// If we have a request to support
// middlewares in that switcher app then
// we can use app.Get("{p:path}"...) instead.
app.UseRouter(func(ctx iris.Context) {
for _, c := range cases {
if c.Filter(ctx) {
c.App.ServeHTTP(ctx.ResponseWriter().Naive(), ctx.Request())
// if c.App.Downgraded() {
// c.App.ServeHTTP(ctx.ResponseWriter(), ctx.Request())
// } else {
// Note(@kataras): don't ever try something like that;
// the context pool is the switcher's one.
// ctx.SetApplication(c.App)
// c.App.ServeHTTPC(ctx)
// ctx.SetApplication(app)
// }
return
}
}
// let the "switch app" handle it or fire a custom 404 error page,
// next is the switch app's router.
ctx.Next()
})
return app
}
type (
// SwitchCase contains the filter
// and the matched Application instance.
SwitchCase struct {
Filter iris.Filter
App *iris.Application
}
// A SwitchProvider should return the switch cases.
// It's an interface instead of a direct slice because
// we want to make available different type of structures
// without wrapping.
SwitchProvider interface {
GetSwitchCases() []SwitchCase
}
// Join returns a new slice which joins different type of switch cases.
Join []SwitchProvider
)
var _ SwitchProvider = SwitchCase{}
// GetSwitchCases completes the SwitchProvider, it returns itself.
func (sc SwitchCase) GetSwitchCases() []SwitchCase {
return []SwitchCase{sc}
}
var _ SwitchProvider = Join{}
// GetSwitchCases completes the switch provider.
func (j Join) GetSwitchCases() (cases []SwitchCase) {
for _, p := range j {
if p == nil {
continue
}
cases = append(cases, p.GetSwitchCases()...)
}
return
}

37
apps/switch_go_test.go Normal file
View File

@ -0,0 +1,37 @@
package apps
import (
"fmt"
"testing"
"github.com/kataras/iris/v12"
)
func TestSwitchJoin(t *testing.T) {
myapp := iris.New()
customFilter := func(ctx iris.Context) bool {
pass, _ := ctx.URLParamBool("filter")
return pass
}
joinedCases := Join{
SwitchCase{
Filter: customFilter,
App: myapp,
},
Hosts{{Pattern: "^test.*$", Target: myapp}},
}
cases := []SwitchCase{
{
Filter: customFilter,
App: myapp,
},
{Filter: hostFilter("^test.*$"), App: myapp},
}
if expected, got := fmt.Sprintf("%#+v", cases), fmt.Sprintf("%#+v", joinedCases.GetSwitchCases()); expected != got {
t.Fatalf("join does not match with the expected slice of cases, expected:\n%s\nbut got:\n%s", expected, got)
}
}

120
apps/switch_hosts.go Normal file
View File

@ -0,0 +1,120 @@
package apps
import (
"fmt"
"net/http"
"net/url"
"regexp"
"github.com/kataras/iris/v12"
"github.com/kataras/iris/v12/context"
)
type (
// Host holds the pattern for the SwitchCase filter
// and the Target host or application.
Host struct {
// Pattern is the incoming host matcher regexp or a literal.
Pattern string
// Target is the target Host that incoming requests will be redirected on pattern match
// or an Application's Name that will handle the incoming request matched the Pattern.
Target interface{} // It was a string in my initial design but let's do that interface{}, we may support more types here in the future, until generics are in, keep it interface{}.
}
// Hosts is a switch provider.
// It can be used as input argument to the `Switch` function
// to map host to existing Iris Application instances, e.g.
// { "www.mydomain.com": "mydomainApp" } .
// It can accept regexp as a host too, e.g.
// { "^my.*$": "mydomainApp" } .
Hosts []Host
// Good by we need order and map can't provide it for us
// (e.g. "fallback" regexp }
// Hosts map[string]*iris.Application
)
var _ SwitchProvider = Hosts{}
// GetSwitchCases completes the SwitchProvider.
// It returns a slice of SwitchCase which
// if passed on `Switch` function, they act
// as a router between matched domains and subdomains
// between existing Iris Applications.
func (hosts Hosts) GetSwitchCases() []SwitchCase {
cases := make([]SwitchCase, 0, len(hosts))
for _, host := range hosts {
cases = append(cases, SwitchCase{
Filter: hostFilter(host.Pattern),
App: hostApp(host),
})
}
return cases
}
func hostApp(host Host) *iris.Application {
if host.Target == nil {
return nil
}
switch target := host.Target.(type) {
case context.Application:
return target.(*iris.Application)
case string:
// Check if the given target is an application name, if so
// we must not redirect (loop) we must serve the request
// using that app.
if targetApp, ok := context.GetApplication(target); ok {
// It's always iris.Application so we are totally safe here.
return targetApp.(*iris.Application)
}
// If it's a real host, warn the user of invalid input.
u, err := url.Parse(target)
if err == nil && u.IsAbs() {
// remember, we redirect hosts, not full URLs here.
panic(fmt.Sprintf(`iris: switch: hosts: invalid target host: "%s"`, target))
}
if regex := regexp.MustCompile(host.Pattern); regex.MatchString(target) {
panic(fmt.Sprintf(`iris: switch: hosts: loop detected between expression: "%s" and target host: "%s"`, host.Pattern, host.Target))
}
return newHostRedirectApp(target, HostsRedirectCode)
default:
panic(fmt.Sprintf("iris: switch: hosts: invalid target type: %T", target))
}
}
func hostFilter(expr string) iris.Filter {
regex := regexp.MustCompile(expr)
return func(ctx iris.Context) bool {
return regex.MatchString(ctx.Host())
}
}
// HostsRedirectCode is the default status code is used
// to redirect a matching host to a url.
var HostsRedirectCode = iris.StatusMovedPermanently
func newHostRedirectApp(targetHost string, code int) *iris.Application {
app := iris.New()
app.Downgrade(func(w http.ResponseWriter, r *http.Request) {
if targetHost == context.GetHost(r) {
// Note(@kataras):
// this should never happen as the HostsRedirect
// carefully checks if the expression already matched the "redirectTo"
// to avoid the redirect loops at all.
// iris: switch: hosts redirect: loop detected between expression: "^my.*$" and target host: "mydomain.com"
http.Error(w, http.StatusText(iris.StatusLoopDetected), iris.StatusLoopDetected)
return
}
r.Host = targetHost
r.URL.Host = targetHost
// r.URL.User = nil
http.Redirect(w, r, r.URL.String(), code)
})
return app
}

211
apps/switch_hosts_test.go Normal file
View File

@ -0,0 +1,211 @@
package apps
import (
"fmt"
"net/url"
"testing"
"github.com/kataras/iris/v12"
"github.com/kataras/iris/v12/context"
"github.com/kataras/iris/v12/httptest"
)
type testRequests map[string]map[string]int // url -> path -> status code
func TestSwitchHosts(t *testing.T) {
var (
expected = func(app context.Application, host string) string {
return fmt.Sprintf("App Name: %s\nHost: %s", app, host)
}
index = func(ctx iris.Context) {
ctx.WriteString(expected(ctx.Application(), ctx.Host()))
}
)
testdomain1 := iris.New().SetName("test 1 domain")
testdomain1.Get("/", index) // should match host matching with "testdomain1.com".
testdomain2 := iris.New().SetName("test 2 domain")
testdomain2.Get("/", index) // should match host matching with "testdomain2.com".
mydomain := iris.New().SetName("my domain")
mydomain.OnErrorCode(iris.StatusNotFound, func(ctx iris.Context) {
ctx.WriteString(ctx.Host() + " custom not found")
})
mydomain.Get("/", index) // should match ALL hosts starting with "my".
tests := []struct {
Pattern string
Target *iris.Application
Requests testRequests
}{
{
"testdomain1.com",
testdomain1,
testRequests{
"http://testdomain1.com": {
"/": iris.StatusOK,
},
},
},
{
"testdomain2.com",
testdomain2,
testRequests{
"http://testdomain2.com": {
"/": iris.StatusOK,
},
},
},
{
"^my.*$",
mydomain,
testRequests{
"http://mydomain.com": {
"/": iris.StatusOK,
"/nf": iris.StatusNotFound,
},
"http://myotherdomain.com": {
"/": iris.StatusOK,
},
"http://mymy.com": {
"/": iris.StatusOK,
},
"http://nmy.com": {
"/": iris.StatusBadGateway, /* 404 hijacked by switch.OnErrorCode */
},
},
},
}
var hosts Hosts
for _, tt := range tests {
hosts = append(hosts, Host{tt.Pattern, tt.Target})
}
switcher := Switch(hosts)
switcher.OnErrorCode(iris.StatusNotFound, func(ctx iris.Context) {
// inject the 404 to 502.
// tests the ctx.Next inside the Hosts switch provider.
ctx.StatusCode(iris.StatusBadGateway)
ctx.WriteString("Switcher: Bad Gateway")
})
e := httptest.New(t, switcher)
for i, tt := range tests {
for URL, paths := range tt.Requests {
u, err := url.Parse(URL)
if err != nil {
t.Fatalf("[%d] %v", i, err)
}
targetHost := u.Host
for requestPath, statusCode := range paths {
// url := fmt.Sprintf("http://%s", requestHost)
body := expected(tt.Target, targetHost)
switch statusCode {
case 404:
body = targetHost + " custom not found"
case 502:
body = "Switcher: Bad Gateway"
}
e.GET(requestPath).WithURL(URL).Expect().Status(statusCode).Body().Equal(body)
}
}
}
}
func TestSwitchHostsRedirect(t *testing.T) {
var (
expected = func(appName, host, path string) string {
return fmt.Sprintf("App Name: %s\nHost: %s\nPath: %s", appName, host, path)
}
index = func(ctx iris.Context) {
ctx.WriteString(expected(ctx.Application().String(), ctx.Host(), ctx.Path()))
}
)
mydomain := iris.New().SetName("mydomain")
mydomain.OnAnyErrorCode(func(ctx iris.Context) {
ctx.WriteString("custom: " + iris.StatusText(ctx.GetStatusCode()))
})
mydomain.Get("/", index)
mydomain.Get("/f", index)
tests := []struct {
Pattern string
Target string
Requests testRequests
}{
{
"www.mydomain.com",
"mydomain",
testRequests{
"http://www.mydomain.com": {
"/": iris.StatusOK,
"/f": iris.StatusOK,
"/nf": iris.StatusNotFound,
},
},
},
{
"^test.*$",
"mydomain",
testRequests{
"http://testdomain.com": {
"/": iris.StatusOK,
"/f": iris.StatusOK,
"/nf": iris.StatusNotFound,
},
},
},
// Something like this will panic to protect users:
// {
// ...,
// "^my.*$",
// "mydomain.com",
// ...
//
{
"^www.*$",
"google.com",
testRequests{
"http://www.mydomain.com": {
"/": iris.StatusOK,
},
"http://www.golang.org": {
"/": iris.StatusNotFound, // should give not found because this is not a switcher's web app.
},
},
},
}
var hostsRedirect Hosts
for _, tt := range tests {
hostsRedirect = append(hostsRedirect, Host{tt.Pattern, tt.Target})
}
switcher := Switch(hostsRedirect)
e := httptest.New(t, switcher)
for i, tt := range tests {
for requestURL, paths := range tt.Requests {
u, err := url.Parse(requestURL)
if err != nil {
t.Fatalf("[%d] %v", i, err)
}
targetHost := u.Host
for requestPath, statusCode := range paths {
body := expected(mydomain.String(), targetHost, requestPath)
if statusCode != 200 {
if tt.Target != mydomain.String() { // it's external.
body = "Not Found"
} else {
body = "custom: " + iris.StatusText(statusCode)
}
}
e.GET(requestPath).WithURL(requestURL).Expect().Status(statusCode).Body().Equal(body)
}
}
}
}

1
apps/switch_scheme.go Normal file
View File

@ -0,0 +1 @@
package apps

View File

@ -84,9 +84,30 @@ type Application interface {
//
// Order may change.
FindClosestPaths(subdomain, searchPath string, n int) []string
// String returns the Application's Name.
String() string
}
// Notes(@kataras):
// Alternative places...
// 1. in apps/store, but it would require an empty `import _ "....apps/store"
// from end-developers, to avoid the import cycle and *iris.Application access.
// 2. in root package level, that could be the best option, it has access to the *iris.Application
// instead of the context.Application interface, but we try to keep the root package
// as minimum as possible, however: if in the future, those Application instances
// can be registered through network instead of same-process then we must think of that choice.
// 3. this is the best possible place, as the root package and all subpackages
// have access to this context package without import cycles and they already using it,
// the only downside is that we don't have access to the *iris.Application instance
// but this context.Application is designed that way that can execute all important methods
// as the whole Iris code base is so well written.
var (
// registerApps holds all the created iris Applications by this process.
// It's slice instead of map because if IRIS_APP_NAME env var exists,
// by-default all applications running on the same machine
// will have the same name unless `Application.SetName` is called.
registeredApps []Application
mu sync.RWMutex
)
@ -107,8 +128,8 @@ func RegisterApplication(app Application) {
// use `Context.Application()` instead.
func LastApplication() Application {
mu.RLock()
if n := len(registeredApps); n > 0 {
if app := registeredApps[n-1]; app != nil {
for i := len(registeredApps) - 1; i >= 0; i-- {
if app := registeredApps[i]; app != nil {
mu.RUnlock()
return app
}
@ -117,6 +138,34 @@ func LastApplication() Application {
return nil
}
// GetApplication returns a registered Application
// based on its name. If the "appName" is not unique
// across Applications, then it will return the newest one.
func GetApplication(appName string) (Application, bool) {
mu.RLock()
for i := len(registeredApps) - 1; i >= 0; i-- {
if app := registeredApps[i]; app != nil && app.String() == appName {
mu.RUnlock()
return app, true
}
}
mu.RUnlock()
return nil, false
}
// MustGetApplication same as `GetApplication` but it
// panics if "appName" is not a registered Application's name.
func MustGetApplication(appName string) Application {
app, ok := GetApplication(appName)
if !ok || app == nil {
panic(appName + " is not a registered Application")
}
return app
}
// DefaultLogger returns a Logger instance for an Iris module.
// If the program contains at least one registered Iris Application
// before this call then it will return a child of that Application's Logger

View File

@ -135,6 +135,14 @@ func NewContext(app Application) *Context {
return &Context{app: app}
}
/* Not required, unless requested.
// SetApplication sets an Iris Application on-fly.
// Do NOT use it after ServeHTTPC is fired.
func (ctx *Context) SetApplication(app Application) {
ctx.app = app
}
*/
// Clone returns a copy of the context that
// can be safely used outside the request's scope.
// Note that if the request-response lifecycle terminated
@ -898,8 +906,22 @@ func (ctx *Context) Domain() string {
return GetDomain(ctx.Host())
}
// SubdomainFull returnst he full subdomain level, e.g.
// GetSubdomainFull returns the full subdomain level, e.g.
// [test.user.]mydomain.com.
func GetSubdomainFull(r *http.Request) string {
host := GetHost(r) // host:port
rootDomain := GetDomain(host) // mydomain.com
rootDomainIdx := strings.Index(host, rootDomain)
if rootDomainIdx == -1 {
return ""
}
return host[0:rootDomainIdx]
}
// SubdomainFull returns the full subdomain level, e.g.
// [test.user.]mydomain.com.
// Note that HostProxyHeaders are being respected here.
func (ctx *Context) SubdomainFull() string {
host := ctx.Host() // host:port
rootDomain := GetDomain(host) // mydomain.com

View File

@ -19,7 +19,7 @@ var (
)
var (
handlerNames = make(map[*nameExpr]string)
handlerNames = make(map[*NameExpr]string)
handlerNamesMu sync.RWMutex
)
@ -44,19 +44,22 @@ func SetHandlerName(original string, replacement string) {
// when a handler name is declared as it's and cause regex parsing expression error,
// e.g. `iris/cache/client.(*Handler).ServeHTTP-fm`
regex, _ := regexp.Compile(original)
handlerNames[&nameExpr{
handlerNames[&NameExpr{
literal: original,
regex: regex,
}] = replacement
handlerNamesMu.Unlock()
}
type nameExpr struct {
// NameExpr regex or literal comparison through `MatchString`.
type NameExpr struct {
regex *regexp.Regexp
literal string
}
func (expr *nameExpr) MatchString(s string) bool {
// MatchString reports whether "s" is literal of "literal"
// or it matches the regex expression at "regex".
func (expr *NameExpr) MatchString(s string) bool {
if expr.literal == s { // if matches as string, as it's.
return true
}

View File

@ -492,6 +492,9 @@ func (su *Supervisor) RegisterOnShutdown(cb func()) {
// for them to close, if desired.
func (su *Supervisor) Shutdown(ctx context.Context) error {
atomic.StoreUint32(&su.closedManually, 1) // future-use
if ctx == nil {
ctx = context.Background()
}
return su.Server.Shutdown(ctx)
}

View File

@ -169,27 +169,6 @@ func (s *subdomainRedirectWrapper) Wrapper(w http.ResponseWriter, r *http.Reques
router(w, r)
}
func redirectAbsolute(w http.ResponseWriter, r *http.Request, url string, code int) {
h := w.Header()
// RFC 7231 notes that a short HTML body is usually included in
// the response because older user agents may not understand 301/307.
// Do it only if the request didn't already have a Content-Type header.
_, hadCT := h[context.ContentTypeHeaderKey]
h.Set("Location", url)
if !hadCT && (r.Method == http.MethodGet || r.Method == http.MethodHead) {
h.Set(context.ContentTypeHeaderKey, "text/html; charset=utf-8")
}
w.WriteHeader(code)
// Shouldn't send the body for POST or HEAD; that leaves GET.
if !hadCT && r.Method == "GET" {
body := "<a href=\"" + template.HTMLEscapeString(url) + "\">" + http.StatusText(code) + "</a>.\n"
fmt.Fprintln(w, body)
}
}
// NewSubdomainPartyRedirectHandler returns a handler which can be registered
// through `UseRouter` or `Use` to redirect from the current request's
// subdomain to the one which the given `to` Party can handle.
@ -210,9 +189,34 @@ func NewSubdomainRedirectHandler(toSubdomain string) context.Handler {
// en-us.test.mydomain.com
host := ctx.Host()
fullSubdomain := ctx.SubdomainFull()
newHost := strings.Replace(host, fullSubdomain, toSubdomain, 1)
resturi := ctx.Request().URL.RequestURI()
urlToRedirect := ctx.Scheme() + newHost + resturi
redirectAbsolute(ctx.ResponseWriter(), ctx.Request(), urlToRedirect, http.StatusMovedPermanently)
targetHost := strings.Replace(host, fullSubdomain, toSubdomain, 1)
// resturi := ctx.Request().URL.RequestURI()
// urlToRedirect := ctx.Scheme() + newHost + resturi
r := ctx.Request()
r.Host = targetHost
r.URL.Host = targetHost
urlToRedirect := r.URL.String()
redirectAbsolute(ctx.ResponseWriter(), r, urlToRedirect, http.StatusMovedPermanently)
}
}
func redirectAbsolute(w http.ResponseWriter, r *http.Request, url string, code int) {
h := w.Header()
// RFC 7231 notes that a short HTML body is usually included in
// the response because older user agents may not understand 301/307.
// Do it only if the request didn't already have a Content-Type header.
_, hadCT := h[context.ContentTypeHeaderKey]
h.Set("Location", url)
if !hadCT && (r.Method == http.MethodGet || r.Method == http.MethodHead) {
h.Set(context.ContentTypeHeaderKey, "text/html; charset=utf-8")
}
w.WriteHeader(code)
// Shouldn't send the body for POST or HEAD; that leaves GET.
if !hadCT && r.Method == "GET" {
body := "<a href=\"" + template.HTMLEscapeString(url) + "\">" + http.StatusText(code) + "</a>.\n"
fmt.Fprintln(w, body)
}
}

69
iris.go
View File

@ -82,8 +82,20 @@ type Application struct {
// used for build
builded bool
defaultMode bool
// OnBuild is a single function which
// is fired on the first `Build` method call.
// If reports an error then the execution
// is stopped and the error is logged.
// It's nil by default except when `Switch` instead of `New` or `Default`
// is used to initialize the Application.
// Users can wrap it to accept more events.
OnBuild func() error
mu sync.Mutex
// name is the application name and the log prefix for
// that Application instance's Logger. See `SetName` and `String`.
// Defaults to IRIS_APP_NAME envrinoment variable otherwise empty.
name string
// Hosts contains a list of all servers (Host Supervisors) that this app is running on.
//
// Hosts may be empty only if application ran(`app.Run`) with `iris.Raw` option runner,
@ -131,6 +143,41 @@ func Default() *Application {
return app
}
func newLogger(app *Application) *golog.Logger {
logger := golog.Default.Child(app)
if name := os.Getenv("IRIS_APP_NAME"); name != "" {
app.name = name
logger.SetChildPrefix(name)
}
return logger
}
// SetName sets a unique name to this Iris Application.
// It sets a child prefix for the current Application's Logger.
// Look `String` method too.
//
// It returns this Application.
func (app *Application) SetName(appName string) *Application {
app.mu.Lock()
defer app.mu.Unlock()
if app.name == "" {
app.logger.SetChildPrefix(appName)
}
app.name = appName
return app
}
// String completes the fmt.Stringer interface and it returns
// the application's name.
// If name was not set by `SetName` or `IRIS_APP_NAME` environment variable
// then this will return an empty string.
func (app *Application) String() string {
return app.name
}
// WWW creates and returns a "www." subdomain.
// The difference from `app.Subdomain("www")` or `app.Party("www.")` is that the `app.WWW()` method
// wraps the router so all http(s)://mydomain.com will be redirect to http(s)://www.mydomain.com.
@ -187,22 +234,6 @@ func (app *Application) ConfigurationReadOnly() context.ConfigurationReadOnly {
return app.config
}
// Maybe, if it's requested:
// func (app *Application) SetName(appName string) *iris.Application {
// app.config.name = appName
// app.logger.SetChildPrefix(appName)
// return app
// }
func newLogger(app *Application) *golog.Logger {
logger := golog.Default.Child(app)
if prefix := os.Getenv("IRIS_APP_NAME"); prefix != "" {
logger.SetChildPrefix(prefix)
}
return logger
}
// Logger returns the golog logger instance(pointer) that is being used inside the "app".
//
// Available levels:
@ -488,6 +519,12 @@ func (app *Application) Build() error {
return nil
}
if cb := app.OnBuild; cb != nil {
if err := cb(); err != nil {
return err
}
}
// start := time.Now()
app.builded = true // even if fails.