diff --git a/.env.example b/.env.example index 14f47df658..e5222c8ac8 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,10 @@ PUBLIC_CONSOLE_MODE=self-hosted PUBLIC_CONSOLE_FEATURE_FLAGS= +# When true: self-hosted region picker + load catalog from GET /console/regions PUBLIC_APPWRITE_MULTI_REGION=false -PUBLIC_APPWRITE_ENDPOINT=http://localhost/v1 +# Leave empty so the SPA uses the current page hostname + /v1. +# Set explicitly only when the API is on a different origin (e.g. local bun dev). +PUBLIC_APPWRITE_ENDPOINT= PUBLIC_STRIPE_KEY= PUBLIC_GROWTH_ENDPOINT= -PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=true \ No newline at end of file +PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=true diff --git a/docker/nginx.conf b/docker/nginx.conf index 82f57204ae..db917f1909 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -21,6 +21,16 @@ server { add_header Cache-Control "public, must-revalidate"; } + # Runtime region catalog (ConfigMap / volume mount). Exact match before SPA fallback. + location = /console/regions { + default_type application/json; + root /usr/share/nginx/html; + try_files /console/regions =404; + expires 0; + add_header Cache-Control "no-cache, no-store"; + add_header Pragma "no-cache"; + } + # All other /console requests (no cache) location /console { index index.html index.html; diff --git a/src/lib/helpers/apiEndpoint.ts b/src/lib/helpers/apiEndpoint.ts index 1bcc1bf3bd..b2773d8af9 100644 --- a/src/lib/helpers/apiEndpoint.ts +++ b/src/lib/helpers/apiEndpoint.ts @@ -12,6 +12,8 @@ import { SUBDOMAIN_SYD, SUBDOMAIN_TOR } from '$lib/constants'; +import { resolveRegionV1Endpoint } from '$lib/helpers/regionHosts'; +import { isCloud } from '$lib/system'; /** Ordered list of region DNS prefixes (e.g. `fra.`) for stripping from API hostnames. */ const REGION_SUBDOMAIN_PREFIXES: readonly string[] = [ @@ -66,9 +68,12 @@ export function getRegionSubdomain(region?: string): string { /** * Builds the `/v1` API base URL (protocol + host + `/v1`). - * When `isMultiRegion` is true and a region is selected, strips any known region prefix from the host, - * then prepends that region. If multi-region is on but no region is requested (`region` missing or - * unknown), the hostname is left unchanged so a default region baked into `APPWRITE_ENDPOINT` is kept. + * + * Self-hosted: + * - single-region / meta: same host as the page (or `PUBLIC_APPWRITE_ENDPOINT` if set) + * - multi-region regional APIs: optional `hostname`/`endpoint` from `/console/regions` + * + * Cloud (multi-region): strip/prepend region DNS labels (`fra.cloud.appwrite.io`, …). */ export function buildRegionalV1Endpoint( protocol: string, @@ -80,6 +85,14 @@ export function buildRegionalV1Endpoint( return `${protocol}//${hostname}/v1`; } + if (!isCloud) { + const override = resolveRegionV1Endpoint(protocol, region); + if (override) { + return override; + } + return `${protocol}//${hostname}/v1`; + } + const subdomain = getRegionSubdomain(region); if (!subdomain) { return `${protocol}//${hostname}/v1`; diff --git a/src/lib/helpers/project.ts b/src/lib/helpers/project.ts index 96561daf8b..858399e764 100644 --- a/src/lib/helpers/project.ts +++ b/src/lib/helpers/project.ts @@ -1,6 +1,6 @@ import { page } from '$app/state'; import { get } from 'svelte/store'; -import { sdk } from '$lib/stores/sdk'; +import { getApiEndpoint } from '$lib/stores/sdk'; import { projectRegion } from '$routes/(console)/project-[region]-[project]/store'; import type { Models } from '@appwrite.io/console'; import { error } from '@sveltejs/kit'; @@ -40,9 +40,7 @@ export function getProjectId(): string | null { */ export function getProjectEndpoint(): string { const currentProjectRegion = get(projectRegion); - const { protocol, hostname, href } = new URL(sdk.forConsole.client.config.endpoint); - - return currentProjectRegion ? `${protocol}//${currentProjectRegion.$id}.${hostname}/v1` : href; + return getApiEndpoint(currentProjectRegion?.$id); } export function isProjectBlocked(project: Models.Project | null | undefined): boolean { diff --git a/src/lib/helpers/regionHosts.test.ts b/src/lib/helpers/regionHosts.test.ts new file mode 100644 index 0000000000..19b8cf9235 --- /dev/null +++ b/src/lib/helpers/regionHosts.test.ts @@ -0,0 +1,61 @@ +import { expect, test, beforeEach } from 'vitest'; +import { + setRegionHosts, + resolveRegionV1Endpoint, + type ConsoleRegionWithHost +} from '$lib/helpers/regionHosts'; + +function region(partial: Partial & { $id: string }): ConsoleRegionWithHost { + return { + name: partial.name ?? partial.$id, + disabled: false, + available: true, + ...partial + } as ConsoleRegionWithHost; +} + +beforeEach(() => { + setRegionHosts([]); +}); + +test('resolveRegionV1Endpoint returns null when catalog is empty', () => { + expect(resolveRegionV1Endpoint('http:', 'fra')).toBeNull(); +}); + +test('resolveRegionV1Endpoint returns null for default or missing region', () => { + setRegionHosts([region({ $id: 'fra', hostname: 'fra.example.com' })]); + expect(resolveRegionV1Endpoint('http:', 'default')).toBeNull(); + expect(resolveRegionV1Endpoint('http:', undefined)).toBeNull(); +}); + +test('resolveRegionV1Endpoint prefers endpoint over hostname', () => { + setRegionHosts([ + region({ + $id: 'nyc', + hostname: 'nyc.example.com', + endpoint: 'https://api.nyc.example.com' + }) + ]); + expect(resolveRegionV1Endpoint('http:', 'nyc')).toBe('https://api.nyc.example.com/v1'); +}); + +test('resolveRegionV1Endpoint keeps /v1 on endpoint and strips trailing slash', () => { + setRegionHosts([ + region({ $id: 'fra', endpoint: 'https://fra.example.com/v1/' }), + region({ $id: 'syd', endpoint: 'https://syd.example.com/' }) + ]); + expect(resolveRegionV1Endpoint('https:', 'fra')).toBe('https://fra.example.com/v1'); + expect(resolveRegionV1Endpoint('https:', 'syd')).toBe('https://syd.example.com/v1'); +}); + +test('resolveRegionV1Endpoint builds URL from hostname and page protocol', () => { + setRegionHosts([region({ $id: 'fra', hostname: 'fra.localhost' })]); + expect(resolveRegionV1Endpoint('http:', 'fra')).toBe('http://fra.localhost/v1'); +}); + +test('setRegionHosts replaces previous catalog entries', () => { + setRegionHosts([region({ $id: 'fra', hostname: 'old.example.com' })]); + setRegionHosts([region({ $id: 'nyc', hostname: 'nyc.example.com' })]); + expect(resolveRegionV1Endpoint('http:', 'fra')).toBeNull(); + expect(resolveRegionV1Endpoint('http:', 'nyc')).toBe('http://nyc.example.com/v1'); +}); diff --git a/src/lib/helpers/regionHosts.ts b/src/lib/helpers/regionHosts.ts new file mode 100644 index 0000000000..c036524113 --- /dev/null +++ b/src/lib/helpers/regionHosts.ts @@ -0,0 +1,54 @@ +import type { Models } from '@appwrite.io/console'; + +export type RegionHostInfo = { + hostname?: string; + endpoint?: string; +}; + +export type ConsoleRegionWithHost = Models.ConsoleRegion & { + hostname?: string; + endpoint?: string; +}; + +const byId = new Map(); + +/** Populate hostname/endpoint overrides from the regions catalog. */ +export function setRegionHosts(list: ConsoleRegionWithHost[] | null | undefined): void { + byId.clear(); + if (!list?.length) return; + + for (const region of list) { + const id = region?.$id; + if (!id) continue; + + const { hostname, endpoint } = region; + if (hostname || endpoint) { + byId.set(id, { hostname, endpoint }); + } + } +} + +/** + * Resolve an explicit regional API base URL from the catalog. + * Returns null when the region has no hostname/endpoint override (Cloud subdomain logic applies). + */ +export function resolveRegionV1Endpoint( + protocol: string, + region: string | undefined +): string | null { + if (!region || region === 'default') return null; + + const info = byId.get(region); + if (!info) return null; + + if (info.endpoint) { + const trimmed = info.endpoint.replace(/\/$/, ''); + return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`; + } + + if (info.hostname) { + return `${protocol}//${info.hostname}/v1`; + } + + return null; +} diff --git a/src/lib/layout/createProject.svelte b/src/lib/layout/createProject.svelte index 5117b5738b..d3f1b36c50 100644 --- a/src/lib/layout/createProject.svelte +++ b/src/lib/layout/createProject.svelte @@ -3,7 +3,7 @@ import { IconPencil } from '@appwrite.io/pink-icons-svelte'; import { CustomId } from '$lib/components/index.js'; import { getFlagUrl } from '$lib/helpers/flag'; - import { isCloud } from '$lib/system.js'; + import { isCloud, isMultiRegion } from '$lib/system.js'; import { Button } from '$lib/elements/forms'; import { page } from '$app/state'; import type { Models } from '@appwrite.io/console'; @@ -80,7 +80,7 @@ - {#if isCloud && regions.length > 0} + {#if (isCloud || isMultiRegion) && regions.length > 0} { + // Self-hosted multi-region: load /console/regions before any child forProject call. + await ensureSelfHostedRegions(); + const parentData = await parent(); const { organizations, plansInfo } = parentData; const account = parentData.account as Account | undefined; diff --git a/src/routes/(console)/onboarding/create-project/+page.svelte b/src/routes/(console)/onboarding/create-project/+page.svelte index 007c4e5f75..60fb930506 100644 --- a/src/routes/(console)/onboarding/create-project/+page.svelte +++ b/src/routes/(console)/onboarding/create-project/+page.svelte @@ -1,7 +1,7 @@