feat: add configurable browser tab title prefix (#2459)
This commit is contained in:
@@ -548,6 +548,7 @@ type routeParams struct {
|
||||
repoCloner *repoclone.Cloner
|
||||
version string
|
||||
webInternalURL string
|
||||
webTitlePrefix string
|
||||
devMode bool
|
||||
httpPort int
|
||||
features config.FeaturesConfig
|
||||
@@ -801,7 +802,7 @@ func webAppHandlerOptions(p routeParams) []webapp.HandlerOption {
|
||||
// webRuntimeConfig builds the SPA's runtime block. `req` supplies the active
|
||||
// locale (from the kandev_locale cookie) so the shell can set <html lang> and the
|
||||
// client can activate the right catalog before first paint.
|
||||
func webRuntimeConfig(debug bool, req *http.Request) webapp.RuntimeConfig {
|
||||
func webRuntimeConfig(debug bool, titlePrefix string, req *http.Request) webapp.RuntimeConfig {
|
||||
return webapp.RuntimeConfig{
|
||||
APIPrefix: "/api/v1",
|
||||
WebSocketPath: "/ws",
|
||||
@@ -812,6 +813,7 @@ func webRuntimeConfig(debug bool, req *http.Request) webapp.RuntimeConfig {
|
||||
// this from its own build mode.
|
||||
NonProduction: profiles.DetectEnvironment() != profiles.EnvProd,
|
||||
Locale: i18n.FromRequest(req),
|
||||
TitlePrefix: strings.TrimSpace(titlePrefix),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -823,7 +825,7 @@ func bootPayload(ctx context.Context, req *http.Request, p routeParams, route we
|
||||
}
|
||||
payload := webapp.NewBootPayload(
|
||||
route,
|
||||
webRuntimeConfig(p.devMode, req),
|
||||
webRuntimeConfig(p.devMode, p.webTitlePrefix, req),
|
||||
initialState,
|
||||
)
|
||||
payload.RouteData = routeData
|
||||
|
||||
@@ -1342,6 +1342,33 @@ func TestBootPayloadIncludesDebugRuntimeWhenDevMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootPayloadCarriesConfiguredTitlePrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload := bootPayload(
|
||||
context.Background(),
|
||||
nil,
|
||||
routeParams{webTitlePrefix: "TEST"},
|
||||
webapp.ClassifyRoute("/"),
|
||||
)
|
||||
if got := payload.Runtime.TitlePrefix; got != "TEST" {
|
||||
t.Fatalf("runtime.titlePrefix = %q, want %q", got, "TEST")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootPayloadOmitsUnsetTitlePrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload := bootPayload(context.Background(), nil, routeParams{}, webapp.ClassifyRoute("/"))
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal payload: %v", err)
|
||||
}
|
||||
if strings.Contains(string(raw), "titlePrefix") {
|
||||
t.Fatalf("expected titlePrefix to be omitted, got: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootPayloadRestoresQuickChatSessions(t *testing.T) {
|
||||
harness := newBootStateTestHarness(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -1844,6 +1844,7 @@ func buildHTTPServer(
|
||||
repoCloner: repoCloner,
|
||||
version: Version,
|
||||
webInternalURL: cfg.Server.WebInternalURL,
|
||||
webTitlePrefix: cfg.Server.WebTitlePrefix,
|
||||
devMode: cfg.Debug.DevMode || cfg.Debug.PprofEnabled,
|
||||
httpPort: resolvedHTTPPort(cfg),
|
||||
features: cfg.Features,
|
||||
|
||||
@@ -10,9 +10,23 @@ import (
|
||||
|
||||
func TestWebRuntimeConfigAdvertisesLSPAutoInstallPreferences(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", "/settings/editors", nil)
|
||||
got := webRuntimeConfig(false, request).LSPAutoInstallPreferenceLanguages
|
||||
got := webRuntimeConfig(false, "", request).LSPAutoInstallPreferenceLanguages
|
||||
want := lspinstaller.AutoInstallPreferenceLanguages()
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("LSP auto-install preferences = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebRuntimeConfigTrimsTitlePrefix(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", "/", nil)
|
||||
if got := webRuntimeConfig(false, " TEST ", request).TitlePrefix; got != "TEST" {
|
||||
t.Fatalf("title prefix = %q, want %q", got, "TEST")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebRuntimeConfigOmitsBlankTitlePrefix(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", "/", nil)
|
||||
if got := webRuntimeConfig(false, " ", request).TitlePrefix; got != "" {
|
||||
t.Fatalf("title prefix = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,10 @@ type ServerConfig struct {
|
||||
ReadTimeout int `mapstructure:"readTimeout"` // in seconds
|
||||
WriteTimeout int `mapstructure:"writeTimeout"` // in seconds
|
||||
WebInternalURL string `mapstructure:"webInternalUrl"`
|
||||
// WebTitlePrefix prefixes the browser tab title as "<prefix> Kandev" (for
|
||||
// example "TEST") so several Kandev instances are distinguishable in
|
||||
// adjacent browser tabs. Empty keeps the plain "Kandev" title.
|
||||
WebTitlePrefix string `mapstructure:"webTitlePrefix"`
|
||||
}
|
||||
|
||||
// splitHosts splits a comma-separated host string, trimming whitespace and
|
||||
@@ -515,6 +519,7 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("server.readTimeout", 30)
|
||||
v.SetDefault("server.writeTimeout", 30)
|
||||
v.SetDefault("server.webInternalUrl", "")
|
||||
v.SetDefault("server.webTitlePrefix", "")
|
||||
|
||||
// HomeDir default — empty means resolve from KANDEV_HOME_DIR env or ~/.kandev
|
||||
v.SetDefault("homeDir", "")
|
||||
@@ -670,6 +675,7 @@ func LoadWithPath(configPath string) (*Config, error) {
|
||||
_ = v.BindEnv("agent.standalonePort", "AGENTCTL_PORT", "KANDEV_AGENT_STANDALONE_PORT")
|
||||
_ = v.BindEnv("agent.standaloneHost", "KANDEV_AGENT_STANDALONE_HOST")
|
||||
_ = v.BindEnv("server.webInternalUrl", "KANDEV_WEB_INTERNAL_URL")
|
||||
_ = v.BindEnv("server.webTitlePrefix", "KANDEV_WEB_TITLE_PREFIX")
|
||||
_ = v.BindEnv("homeDir", "KANDEV_HOME_DIR")
|
||||
_ = v.BindEnv("logging.level", "KANDEV_LOG_LEVEL")
|
||||
_ = v.BindEnv("events.namespace", "KANDEV_EVENTS_NAMESPACE")
|
||||
|
||||
@@ -32,6 +32,28 @@ func TestGitHubCredentialBrokerConfigEnvironmentBinding(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebTitlePrefixEnvironmentBinding(t *testing.T) {
|
||||
t.Setenv("KANDEV_WEB_TITLE_PREFIX", "TEST")
|
||||
cfg, err := LoadWithPath(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithPath: %v", err)
|
||||
}
|
||||
if got := cfg.Server.WebTitlePrefix; got != "TEST" {
|
||||
t.Fatalf("web title prefix = %q, want %q", got, "TEST")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebTitlePrefixDefaultsEmpty(t *testing.T) {
|
||||
t.Setenv("KANDEV_WEB_TITLE_PREFIX", "")
|
||||
cfg, err := LoadWithPath(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithPath: %v", err)
|
||||
}
|
||||
if got := cfg.Server.WebTitlePrefix; got != "" {
|
||||
t.Fatalf("web title prefix = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// minimalValidConfig returns a Config that passes validate() out of the box.
|
||||
// Tests modify a copy to exercise individual validation branches.
|
||||
func minimalValidConfig() *Config {
|
||||
|
||||
@@ -46,6 +46,13 @@ type RuntimeConfig struct {
|
||||
// before first paint. Sourced from the kandev_locale cookie; defaults to
|
||||
// "en". Also drives the shell's <html lang> so first paint matches.
|
||||
Locale string `json:"locale,omitempty"`
|
||||
// TitlePrefix distinguishes Kandev instances in adjacent browser tabs. It
|
||||
// carries the operator-configured prefix (KANDEV_WEB_TITLE_PREFIX) with
|
||||
// surrounding whitespace trimmed, not the composed title: the shell
|
||||
// rewrites <title> server-side for first paint, and the SPA composes the
|
||||
// same "<prefix> Kandev" for the
|
||||
// /api/v1/app-state boot path, which never renders through the shell.
|
||||
TitlePrefix string `json:"titlePrefix,omitempty"`
|
||||
}
|
||||
|
||||
// BootError is a serializable non-fatal boot-data error for partial hydration.
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io/fs"
|
||||
"strings"
|
||||
|
||||
"github.com/kandev/kandev/internal/i18n"
|
||||
)
|
||||
@@ -17,6 +19,14 @@ var headCloseTag = []byte("</head>")
|
||||
|
||||
var htmlOpenTag = []byte("<html")
|
||||
|
||||
var titleOpenTag = []byte("<title")
|
||||
|
||||
var titleCloseTag = []byte("</title>")
|
||||
|
||||
// ProductTitle is the browser tab title when no title prefix is configured.
|
||||
// It is a product name, not translatable copy.
|
||||
const ProductTitle = "Kandev"
|
||||
|
||||
// DefaultLocale is the source locale shipped as the message catalog.
|
||||
const DefaultLocale = i18n.DefaultLocale
|
||||
|
||||
@@ -45,7 +55,62 @@ func RenderShellHTML(indexHTML []byte, payload BootPayload) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
localized := rewriteHTMLLang(indexHTML, NormalizeLocale(payload.Runtime.Locale))
|
||||
return injectBeforeHeadClose(localized, script), nil
|
||||
titled := rewriteTitle(localized, payload.Runtime.TitlePrefix)
|
||||
return injectBeforeHeadClose(titled, script), nil
|
||||
}
|
||||
|
||||
// ComposeTitle returns the browser tab title for prefix: a trimmed-empty prefix
|
||||
// keeps the plain product name, otherwise "<prefix> Kandev". The SPA repeats
|
||||
// this rule in apps/web/lib/browser/document-title.ts for the
|
||||
// /api/v1/app-state boot path, which never renders through this shell.
|
||||
func ComposeTitle(prefix string) string {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix == "" {
|
||||
return ProductTitle
|
||||
}
|
||||
return prefix + " " + ProductTitle
|
||||
}
|
||||
|
||||
// rewriteTitle replaces the content of the shell's first <title> element so the
|
||||
// tab title is correct from first paint, with no JS-driven flash. A blank
|
||||
// prefix, or a shell with no <title> element, leaves the input unchanged.
|
||||
func rewriteTitle(indexHTML []byte, prefix string) []byte {
|
||||
if strings.TrimSpace(prefix) == "" {
|
||||
return indexHTML
|
||||
}
|
||||
start, end, ok := titleContentBounds(indexHTML)
|
||||
if !ok {
|
||||
return indexHTML
|
||||
}
|
||||
title := html.EscapeString(ComposeTitle(prefix))
|
||||
|
||||
out := make([]byte, 0, bytesCapacity(len(indexHTML), len(title)))
|
||||
out = append(out, indexHTML[:start]...)
|
||||
out = append(out, title...)
|
||||
out = append(out, indexHTML[end:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// titleContentBounds locates the text between the first <title ...> and its
|
||||
// closing </title>, reporting false when either tag is missing. The byte scan
|
||||
// assumes the shell's first "<title" is the head's title element — the same
|
||||
// assumption rewriteHTMLLang makes for "<html" — which Vite-generated shells
|
||||
// satisfy; a "<title" inside an earlier comment or script would fool it.
|
||||
func titleContentBounds(indexHTML []byte) (start, end int, ok bool) {
|
||||
tagStart := bytes.Index(indexHTML, titleOpenTag)
|
||||
if tagStart < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
tagEnd := bytes.IndexByte(indexHTML[tagStart:], '>')
|
||||
if tagEnd < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
start = tagStart + tagEnd + 1
|
||||
closeIdx := bytes.Index(indexHTML[start:], titleCloseTag)
|
||||
if closeIdx < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return start, start + closeIdx, true
|
||||
}
|
||||
|
||||
// rewriteHTMLLang sets the lang="" attribute on the shell's opening <html> tag
|
||||
|
||||
@@ -157,6 +157,76 @@ func TestRenderShellInsertsLangWhenAbsent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderShellRewritesTitleFromPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
prefix string
|
||||
wantTitle string
|
||||
}{
|
||||
{name: "prefix set", prefix: "TEST", wantTitle: "<title>TEST Kandev</title>"},
|
||||
{name: "prefix trimmed", prefix: " TEST ", wantTitle: "<title>TEST Kandev</title>"},
|
||||
{name: "empty prefix keeps shell title", prefix: "", wantTitle: "<title>Kandev</title>"},
|
||||
{name: "blank prefix keeps shell title", prefix: " ", wantTitle: "<title>Kandev</title>"},
|
||||
{
|
||||
name: "html special characters are escaped",
|
||||
prefix: `</title><script>alert(1)</script>`,
|
||||
wantTitle: "<title></title><script>alert(1)</script> Kandev</title>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assets := fstest.MapFS{
|
||||
"index.html": {
|
||||
Data: []byte(`<!doctype html><html lang="en"><head><title>Kandev</title></head><body></body></html>`),
|
||||
},
|
||||
}
|
||||
payload := NewBootPayload(ClassifyRoute("/"), RuntimeConfig{TitlePrefix: tc.prefix}, nil)
|
||||
html, err := RenderShell(assets, "index.html", payload)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderShell: %v", err)
|
||||
}
|
||||
if got := string(html); !strings.Contains(got, tc.wantTitle) {
|
||||
t.Fatalf("expected %q in shell, got: %s", tc.wantTitle, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderShellLeavesShellWithoutTitleUntouched(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assets := fstest.MapFS{
|
||||
"index.html": {Data: []byte(`<!doctype html><html lang="en"><head></head><body></body></html>`)},
|
||||
}
|
||||
payload := NewBootPayload(ClassifyRoute("/"), RuntimeConfig{TitlePrefix: "TEST"}, nil)
|
||||
html, err := RenderShell(assets, "index.html", payload)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderShell: %v", err)
|
||||
}
|
||||
if strings.Contains(string(html), "<title") {
|
||||
t.Fatalf("expected no title element to be created, got: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"", "Kandev"},
|
||||
{" ", "Kandev"},
|
||||
{"TEST", "TEST Kandev"},
|
||||
{" staging ", "staging Kandev"},
|
||||
} {
|
||||
if got := ComposeTitle(tc.in); got != tc.want {
|
||||
t.Fatalf("ComposeTitle(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLocale(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyTitlePrefix, composeTitle } from "./document-title";
|
||||
|
||||
function fakeDocument(title: string): Document {
|
||||
return { title } as Document;
|
||||
}
|
||||
|
||||
describe("composeTitle", () => {
|
||||
it.each([
|
||||
["TEST", "TEST Kandev"],
|
||||
[" staging ", "staging Kandev"],
|
||||
["", "Kandev"],
|
||||
[" ", "Kandev"],
|
||||
[undefined, "Kandev"],
|
||||
])("composes %o as %s", (prefix, expected) => {
|
||||
expect(composeTitle(prefix)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyTitlePrefix", () => {
|
||||
it("prefixes the document title", () => {
|
||||
const doc = fakeDocument("Kandev");
|
||||
applyTitlePrefix("TEST", doc);
|
||||
expect(doc.title).toBe("TEST Kandev");
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace from the prefix", () => {
|
||||
const doc = fakeDocument("Kandev");
|
||||
applyTitlePrefix(" TEST ", doc);
|
||||
expect(doc.title).toBe("TEST Kandev");
|
||||
});
|
||||
|
||||
it.each([undefined, "", " "])("leaves the title alone for %o", (prefix) => {
|
||||
const doc = fakeDocument("Kandev");
|
||||
applyTitlePrefix(prefix, doc);
|
||||
expect(doc.title).toBe("Kandev");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Browser tab title composition. Mirrors the Go rule in
|
||||
* `apps/backend/internal/webapp/shell.go` (`ComposeTitle`): the shell rewrites
|
||||
* `<title>` server-side so the prefixed title is correct from first paint, and
|
||||
* this covers the `/api/v1/app-state` boot path, which never renders through
|
||||
* the shell.
|
||||
*
|
||||
* "Kandev" is the product name, not translatable copy, so no `t()` is involved
|
||||
* — consistent with the static `<title>` in `index.html`.
|
||||
*/
|
||||
const PRODUCT_TITLE = "Kandev";
|
||||
|
||||
/** Returns `"<prefix> Kandev"`, or the plain product name for a blank prefix. */
|
||||
export function composeTitle(prefix: string | undefined): string {
|
||||
const trimmed = prefix?.trim();
|
||||
return trimmed ? `${trimmed} ${PRODUCT_TITLE}` : PRODUCT_TITLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the operator-configured tab title prefix. A blank prefix leaves the
|
||||
* document title alone: the served shell already says "Kandev", and
|
||||
* overwriting it would clobber a title some other surface has set.
|
||||
*/
|
||||
export function applyTitlePrefix(prefix: string | undefined, doc: Document): void {
|
||||
if (!prefix?.trim()) return;
|
||||
doc.title = composeTitle(prefix);
|
||||
}
|
||||
@@ -76,6 +76,22 @@ describe("readBootPayload", () => {
|
||||
expect(readBootPayload(win).runtime?.debug).toBe(true);
|
||||
expect(win.__KANDEV_DEBUG).toBe(true);
|
||||
});
|
||||
|
||||
it("reads the browser tab title prefix from the runtime block", () => {
|
||||
const win = {
|
||||
__KANDEV_BOOT_PAYLOAD__: { runtime: { titlePrefix: "TEST" } },
|
||||
} as unknown as Window;
|
||||
|
||||
expect(readBootPayload(win).runtime?.titlePrefix).toBe("TEST");
|
||||
});
|
||||
|
||||
it("leaves the title prefix undefined when the runtime block omits it", () => {
|
||||
const win = {
|
||||
__KANDEV_BOOT_PAYLOAD__: { runtime: { apiPrefix: "/api/v1" } },
|
||||
} as unknown as Window;
|
||||
|
||||
expect(readBootPayload(win).runtime?.titlePrefix).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readBootPayload plugins", () => {
|
||||
|
||||
@@ -26,6 +26,13 @@ export type BootRuntime = {
|
||||
nonProduction?: boolean;
|
||||
/** Active UI locale from the kandev_locale cookie; drives first-paint i18n. */
|
||||
locale?: string;
|
||||
/**
|
||||
* Operator-configured browser tab title prefix (KANDEV_WEB_TITLE_PREFIX), so
|
||||
* several Kandev instances are distinguishable in adjacent tabs. The Go shell
|
||||
* already rewrites `<title>`; this covers the /api/v1/app-state boot path,
|
||||
* which never renders through the shell.
|
||||
*/
|
||||
titlePrefix?: string;
|
||||
};
|
||||
|
||||
export type BootRouteData = {
|
||||
@@ -146,6 +153,7 @@ function readRuntime(value: Record<string, unknown>): BootRuntime {
|
||||
debug: value.debug === true ? true : undefined,
|
||||
nonProduction: value.nonProduction === true ? true : undefined,
|
||||
locale: readString(value.locale),
|
||||
titlePrefix: readString(value.titlePrefix),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { RootErrorBoundary, RouteErrorBoundary } from "./app-error-boundary";
|
||||
import { SpaRoutes } from "./spa-routes";
|
||||
import { installVitePreloadRecovery } from "./vite-preload-recovery";
|
||||
import { markRenderingEngine } from "@/lib/browser/rendering-engine";
|
||||
import { applyTitlePrefix } from "@/lib/browser/document-title";
|
||||
|
||||
installVitePreloadRecovery();
|
||||
markRenderingEngine(document.documentElement);
|
||||
@@ -70,6 +71,10 @@ if (!root) {
|
||||
}
|
||||
|
||||
void loadBootPayload().then(async (payload) => {
|
||||
// The Go shell already rewrote <title>; this is the /api/v1/app-state boot
|
||||
// path, which never renders through it.
|
||||
applyTitlePrefix(payload.runtime?.titlePrefix, document);
|
||||
|
||||
// Only `en` ships in the entry chunk, so a non-English boot has to fetch its
|
||||
// catalogs. Awaited HERE, in the promise the mount already waited on, rather
|
||||
// than after mounting: with `returnNull: false` a missing key renders as the
|
||||
|
||||
@@ -26,6 +26,7 @@ All env vars use the `KANDEV_` prefix. Nested keys map by replacing `.` with `_`
|
||||
|---|---|
|
||||
| `server.port` | `KANDEV_SERVER_PORT` |
|
||||
| `server.webInternalUrl` | `KANDEV_SERVER_WEBINTERNALURL` (or alias `KANDEV_WEB_INTERNAL_URL`) |
|
||||
| `server.webTitlePrefix` | `KANDEV_SERVER_WEBTITLEPREFIX` (or alias `KANDEV_WEB_TITLE_PREFIX`) |
|
||||
| `database.dbName` | `KANDEV_DATABASE_DBNAME` |
|
||||
| `logging.maxSizeMb` | `KANDEV_LOGGING_MAXSIZEMB` |
|
||||
| `homeDir` | `KANDEV_HOME_DIR` (alias) |
|
||||
@@ -48,6 +49,7 @@ server:
|
||||
readTimeout: 30 # seconds
|
||||
writeTimeout: 30 # seconds
|
||||
webInternalUrl: "" # internal URL the backend uses to call the web app
|
||||
webTitlePrefix: "" # "TEST" -> browser tab title "TEST Kandev"
|
||||
|
||||
database:
|
||||
driver: "sqlite" # "sqlite" or "postgres"
|
||||
|
||||
@@ -58,6 +58,7 @@ Some common camelCase keys have explicit compatibility aliases. Use the document
|
||||
| `server.readTimeout` | `KANDEV_SERVER_READTIMEOUT` | `30` | HTTP read timeout in seconds. |
|
||||
| `server.writeTimeout` | `KANDEV_SERVER_WRITETIMEOUT` | `30` | HTTP write timeout in seconds. |
|
||||
| `server.webInternalUrl` | `KANDEV_WEB_INTERNAL_URL` | empty | Development reverse-proxy target for a separately running web app. Installed releases normally serve embedded assets. |
|
||||
| `server.webTitlePrefix` | `KANDEV_WEB_TITLE_PREFIX` | empty | Prefixes the browser tab title as `<prefix> Kandev` (for example `TEST` renders `TEST Kandev`), so several instances stay distinguishable in adjacent tabs. Empty keeps the plain `Kandev` title. |
|
||||
|
||||
The default host exposes the server on every interface even though the CLI prints a `localhost` URL. The current local product path must not be treated as an authenticated multi-user perimeter. For remote access, bind to loopback and use a trusted authenticated tunnel/proxy, or isolate the network at the deployment layer.
|
||||
|
||||
@@ -212,6 +213,7 @@ server:
|
||||
readTimeout: 30
|
||||
writeTimeout: 30
|
||||
webInternalUrl: ""
|
||||
webTitlePrefix: ""
|
||||
|
||||
database:
|
||||
driver: "sqlite"
|
||||
|
||||
Reference in New Issue
Block a user