diff --git a/js/core/src/init.svelte.ts b/js/core/src/init.svelte.ts index d0750948ef..4312270ea4 100644 --- a/js/core/src/init.svelte.ts +++ b/js/core/src/init.svelte.ts @@ -40,6 +40,20 @@ const type_map = { walkthrough: "tabs", walkthroughstep: "tabitem" }; + +export function get_api_url(config: Omit): string { + // Handle api_prefix correctly when app is mounted at a subpath. + // config.root may not include a trailing slash, so we normalize its pathname + // before appending api_prefix to ensure correct URL construction. + const rootUrl = new URL(config.root); + const rootPath = rootUrl.pathname.endsWith("/") + ? rootUrl.pathname + : rootUrl.pathname + "/"; + const apiPrefix = config.api_prefix.startsWith("/") + ? config.api_prefix + : "/" + config.api_prefix; + return new URL(rootPath.slice(0, -1) + apiPrefix, rootUrl.origin).toString(); +} export class AppTree { /** the raw component structure received from the backend */ #component_payload: ComponentMeta[]; @@ -91,9 +105,10 @@ export class AppTree { this.ready_resolve = resolve; }); this.reactive_formatter = reactive_formatter; + const api_url = get_api_url(config); this.#config = { ...config, - api_url: new URL(config.api_prefix, config.root).toString() + api_url }; this.#component_payload = components; this.#layout_payload = layout; @@ -144,9 +159,10 @@ export class AppTree { ) { this.#layout_payload = layout; this.#component_payload = components; + const api_url = get_api_url(config); this.#config = { ...config, - api_url: new URL(config.api_prefix, config.root).toString() + api_url }; this.#dependency_payload = dependencies; diff --git a/js/core/src/init.test.skip.ts b/js/core/src/init.test.skip.ts index 699831a622..ddea269fca 100644 --- a/js/core/src/init.test.skip.ts +++ b/js/core/src/init.test.skip.ts @@ -11,6 +11,8 @@ import { process_server_fn, get_component } from "./_init"; +import { get_api_url } from "./init.svelte"; +import type { AppConfig } from "./types"; import { commands } from "@vitest/browser/context"; describe("process_frontend_fn", () => { @@ -525,3 +527,297 @@ describe("get_component", () => { worker.stop(); }); }); + +describe("get_api_url", () => { + describe("root URL with trailing slash", () => { + test("root with trailing slash, api_prefix with leading slash", () => { + const config: Omit = { + root: "http://example.com/myapp/", + api_prefix: "/api", + theme: "default", + version: "1.0.0", + autoscroll: true + }; + const result = get_api_url(config); + expect(result).toBe("http://example.com/myapp/api"); + }); + + test("root with trailing slash, api_prefix without leading slash", () => { + const config: Omit = { + root: "http://example.com/myapp/", + api_prefix: "api", + theme: "default", + version: "1.0.0", + autoscroll: true + }; + const result = get_api_url(config); + expect(result).toBe("http://example.com/myapp/api"); + }); + + test("root at domain root with trailing slash", () => { + const config: Omit = { + root: "http://example.com/", + api_prefix: "/api", + theme: "default", + version: "1.0.0", + autoscroll: true + }; + const result = get_api_url(config); + expect(result).toBe("http://example.com/api"); + }); + }); + + describe("root URL without trailing slash", () => { + test("root without trailing slash, api_prefix with leading slash", () => { + const config: Omit = { + root: "http://example.com/myapp", + api_prefix: "/api", + theme: "default", + version: "1.0.0", + autoscroll: true + }; + const result = get_api_url(config); + expect(result).toBe("http://example.com/myapp/api"); + }); + + test("root without trailing slash, api_prefix without leading slash", () => { + const config: Omit = { + root: "http://example.com/myapp", + api_prefix: "api", + theme: "default", + version: "1.0.0", + autoscroll: true + }; + const result = get_api_url(config); + expect(result).toBe("http://example.com/myapp/api"); + }); + + test("root at domain root without trailing slash", () => { + const config: Omit = { + root: "http://example.com", + api_prefix: "/api", + theme: "default", + version: "1.0.0", + autoscroll: true + }; + const result = get_api_url(config); + expect(result).toBe("http://example.com/api"); + }); + }); + + describe("different root path combinations", () => { + test("root path is just '/'", () => { + const config: Omit = { + root: "http://example.com/", + api_prefix: "/api", + theme: "default", + version: "1.0.0", + autoscroll: true + }; + const result = get_api_url(config); + expect(result).toBe("http://example.com/api"); + }); + + test("root path is '/' without trailing slash", () => { + const config: Omit = { + root: "http://example.com", + api_prefix: "/api", + theme: "default", + version: "1.0.0", + autoscroll: true + }; + const result = get_api_url(config); + expect(result).toBe("http://example.com/api"); + }); + + test("root path is '/myapp'", () => { + const config: Omit = { + root: "http://example.com/myapp", + api_prefix: "/api", + theme: "default", + version: "1.0.0", + autoscroll: true + }; + const result = get_api_url(config); + expect(result).toBe("http://example.com/myapp/api"); + }); + + test("root path is '/myapp/'", () => { + const config: Omit = { + root: "http://example.com/myapp/", + api_prefix: "/api", + theme: "default", + version: "1.0.0", + autoscroll: true + }; + const result = get_api_url(config); + expect(result).toBe("http://example.com/myapp/api"); + }); + + test("root path is '/deep/nested/path'", () => { + const config: Omit = { + root: "http://example.com/deep/nested/path", + api_prefix: "/api", + theme: "default", + version: "1.0.0", + autoscroll: true + }; + const result = get_api_url(config); + expect(result).toBe("http://example.com/deep/nested/path/api"); + }); + + test("root path is '/deep/nested/path/'", () => { + const config: Omit = { + root: "http://example.com/deep/nested/path/", + api_prefix: "/api", + theme: "default", + version: "1.0.0", + autoscroll: true + }; + const result = get_api_url(config); + expect(result).toBe("http://example.com/deep/nested/path/api"); + }); + }); + + describe("different api_prefix formats", () => { + test("api_prefix with leading slash", () => { + const config: Omit = { + root: "http://example.com/myapp", + api_prefix: "/api", + theme: "default", + version: "1.0.0", + autoscroll: true + }; + const result = get_api_url(config); + expect(result).toBe("http://example.com/myapp/api"); + }); + + test("api_prefix without leading slash", () => { + const config: Omit = { + root: "http://example.com/myapp", + api_prefix: "api", + theme: "default", + version: "1.0.0", + autoscroll: true + }; + const result = get_api_url(config); + expect(result).toBe("http://example.com/myapp/api"); + }); + + test("api_prefix with nested path and leading slash", () => { + const config: Omit = { + root: "http://example.com/myapp", + api_prefix: "/api/v1", + theme: "default", + version: "1.0.0", + autoscroll: true + }; + const result = get_api_url(config); + expect(result).toBe("http://example.com/myapp/api/v1"); + }); + + test("api_prefix with nested path without leading slash", () => { + const config: Omit = { + root: "http://example.com/myapp", + api_prefix: "api/v1", + theme: "default", + version: "1.0.0", + autoscroll: true + }; + const result = get_api_url(config); + expect(result).toBe("http://example.com/myapp/api/v1"); + }); + }); + + describe("edge cases", () => { + test("root with port number", () => { + const config: Omit = { + root: "http://example.com:8080/myapp", + api_prefix: "/api", + theme: "default", + version: "1.0.0", + autoscroll: true + }; + const result = get_api_url(config); + expect(result).toBe("http://example.com:8080/myapp/api"); + }); + + test("root with HTTPS", () => { + const config: Omit = { + root: "https://example.com/myapp", + api_prefix: "/api", + theme: "default", + version: "1.0.0", + autoscroll: true + }; + const result = get_api_url(config); + expect(result).toBe("https://example.com/myapp/api"); + }); + + test("root with query parameters (should be ignored)", () => { + const config: Omit = { + root: "http://example.com/myapp?param=value", + api_prefix: "/api", + theme: "default", + version: "1.0.0", + autoscroll: true + }; + const result = get_api_url(config); + expect(result).toBe("http://example.com/myapp/api"); + }); + + test("root with hash (should be ignored)", () => { + const config: Omit = { + root: "http://example.com/myapp#section", + api_prefix: "/api", + theme: "default", + version: "1.0.0", + autoscroll: true + }; + const result = get_api_url(config); + expect(result).toBe("http://example.com/myapp/api"); + }); + }); + + describe("consistency checks", () => { + test("same result regardless of root trailing slash", () => { + const baseConfig = { + api_prefix: "/api", + theme: "default", + version: "1.0.0", + autoscroll: true + }; + + const config1: Omit = { + ...baseConfig, + root: "http://example.com/myapp" + }; + const config2: Omit = { + ...baseConfig, + root: "http://example.com/myapp/" + }; + + expect(get_api_url(config1)).toBe(get_api_url(config2)); + }); + + test("same result regardless of api_prefix leading slash", () => { + const baseConfig = { + root: "http://example.com/myapp", + theme: "default", + version: "1.0.0", + autoscroll: true + }; + + const config1: Omit = { + ...baseConfig, + api_prefix: "/api" + }; + const config2: Omit = { + ...baseConfig, + api_prefix: "api" + }; + + expect(get_api_url(config1)).toBe(get_api_url(config2)); + }); + }); +});