From a82d0e6875754f57056daf42e01a9660f2e0f94d Mon Sep 17 00:00:00 2001 From: Ibaraki Douji Date: Thu, 6 Aug 2026 22:17:42 +0200 Subject: [PATCH 1/5] feat(regions): implement multi-region support and enhance region handling --- .env.example | 3 +- docker/nginx.conf | 10 +++ src/lib/helpers/apiEndpoint.ts | 7 +++ src/lib/helpers/project.ts | 6 +- src/lib/helpers/regionHosts.test.ts | 61 +++++++++++++++++++ src/lib/helpers/regionHosts.ts | 54 ++++++++++++++++ src/lib/layout/createProject.svelte | 4 +- src/lib/system.ts | 1 + .../onboarding/create-project/+page.svelte | 4 +- .../organization-[organization]/+page.svelte | 4 +- .../createProject.svelte | 41 +++++++++++-- src/routes/(console)/regions.ts | 59 +++++++++++++++--- 12 files changed, 229 insertions(+), 25 deletions(-) create mode 100644 src/lib/helpers/regionHosts.test.ts create mode 100644 src/lib/helpers/regionHosts.ts diff --git a/.env.example b/.env.example index 14f47df658..ef47c6acb5 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,8 @@ 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 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..280c50707f 100644 --- a/src/lib/helpers/apiEndpoint.ts +++ b/src/lib/helpers/apiEndpoint.ts @@ -12,6 +12,7 @@ import { SUBDOMAIN_SYD, SUBDOMAIN_TOR } from '$lib/constants'; +import { resolveRegionV1Endpoint } from '$lib/helpers/regionHosts'; /** Ordered list of region DNS prefixes (e.g. `fra.`) for stripping from API hostnames. */ const REGION_SUBDOMAIN_PREFIXES: readonly string[] = [ @@ -80,6 +81,12 @@ export function buildRegionalV1Endpoint( return `${protocol}//${hostname}/v1`; } + // Self-hosted: optional hostname/endpoint from /console/regions catalog + const override = resolveRegionV1Endpoint(protocol, region); + if (override) { + return override; + } + 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} import { Card, Layout, Button } from '@appwrite.io/pink-svelte'; import { Form } from '$lib/elements/forms'; - import { isCloud } from '$lib/system'; + import { isCloud, isMultiRegion } from '$lib/system'; import { sdk } from '$lib/stores/sdk'; import { ID, Region } from '@appwrite.io/console'; import Loading from './loading.svelte'; @@ -46,7 +46,7 @@ const project = await sdk.forConsole.organization(teamId).createProject({ projectId: projectId ?? ID.unique(), name: projectName, - region: isCloud ? projectRegion : undefined + region: isCloud || isMultiRegion ? projectRegion : undefined }); markOnboardingComplete(); diff --git a/src/routes/(console)/organization-[organization]/+page.svelte b/src/routes/(console)/organization-[organization]/+page.svelte index 044e88d0bd..8778440961 100644 --- a/src/routes/(console)/organization-[organization]/+page.svelte +++ b/src/routes/(console)/organization-[organization]/+page.svelte @@ -5,7 +5,7 @@ import { Container } from '$lib/layout'; import CreateProject from './createProject.svelte'; import CreateOrganization from '../createOrganization.svelte'; - import { GRACE_PERIOD_OVERRIDE, isCloud } from '$lib/system'; + import { GRACE_PERIOD_OVERRIDE, isCloud, isMultiRegion } from '$lib/system'; import { page } from '$app/state'; import { registerCommands } from '$lib/commandCenter'; import { @@ -291,7 +291,7 @@ {/if} - {#if isCloud && $regionsStore?.regions} + {#if (isCloud || isMultiRegion) && $regionsStore?.regions} {@const region = findRegion(project)} {region.name} {/if} diff --git a/src/routes/(console)/organization-[organization]/createProject.svelte b/src/routes/(console)/organization-[organization]/createProject.svelte index 47f0547ac5..badb7d1965 100644 --- a/src/routes/(console)/organization-[organization]/createProject.svelte +++ b/src/routes/(console)/organization-[organization]/createProject.svelte @@ -3,12 +3,15 @@ import { base } from '$app/paths'; import { Submit, trackEvent, trackError } from '$lib/actions/analytics'; import { Modal, CustomId } from '$lib/components'; - import { InputText, Button } from '$lib/elements/forms'; + import { InputText, Button, InputSelect } from '$lib/elements/forms'; import { addNotification } from '$lib/stores/notifications'; import { sdk } from '$lib/stores/sdk'; - import { ID } from '@appwrite.io/console'; + import { regions as regionsStore } from '$lib/stores/organization'; + import { isMultiRegion } from '$lib/system'; + import { filterRegions } from '$lib/helpers/regions'; + import { ID, type Region } from '@appwrite.io/console'; import { IconPencil } from '@appwrite.io/pink-icons-svelte'; - import { Icon, Layout, Tag } from '@appwrite.io/pink-svelte'; + import { Icon, Layout, Tag, Typography } from '@appwrite.io/pink-svelte'; import { createEventDispatcher } from 'svelte'; export let show = false; @@ -22,20 +25,34 @@ let disabled: boolean = false; let name: string = 'New project'; let showSubmissionLoader = false; + let region: string = ''; + + $: regionOptions = filterRegions($regionsStore.regions || []); + $: if ( + regionOptions.length && + !regionOptions.some((option) => option.value === region && !option.disabled) + ) { + region = regionOptions.find((option) => !option.disabled)?.value ?? regionOptions[0].value; + } async function create() { try { disabled = true; showSubmissionLoader = true; - const project = await sdk.forConsole.organization(teamId).createProject({ + const payload: { projectId: string; name: string; region?: Region } = { projectId: id || ID.unique(), name - }); + }; + if (isMultiRegion && region) { + payload.region = region as Region; + } + const project = await sdk.forConsole.organization(teamId).createProject(payload); show = false; dispatch('created', project); trackEvent(Submit.ProjectCreate, { customId: !!id, - teamId + teamId, + region: project.region }); addNotification({ type: 'success', @@ -55,6 +72,18 @@ + {#if isMultiRegion && regionOptions.length > 0} + + + Region cannot be changed after creation + + {/if} {#if !showCustomId} (showCustomId = !showCustomId)}> diff --git a/src/routes/(console)/regions.ts b/src/routes/(console)/regions.ts index 9c411a82f0..9f7d468ea5 100644 --- a/src/routes/(console)/regions.ts +++ b/src/routes/(console)/regions.ts @@ -1,32 +1,75 @@ import { get } from 'svelte/store'; +import { base } from '$app/paths'; import { sdk } from '$lib/stores/sdk'; -import { isCloud } from '$lib/system'; +import { isCloud, isMultiRegion } from '$lib/system'; import { regions } from '$lib/stores/organization'; +import { setRegionHosts, type ConsoleRegionWithHost } from '$lib/helpers/regionHosts'; +import type { Models } from '@appwrite.io/console'; let lastLoadedOrganization = null; +async function loadSelfHostedRegions(): Promise { + try { + const res = await fetch(`${base}/regions`, { cache: 'no-store' }); + if (!res.ok) { + console.error(`Failed to fetch ${base}/regions: ${res.status}`); + return null; + } + + const data = await res.json(); + let list: ConsoleRegionWithHost[] = []; + if (Array.isArray(data)) { + list = data; + } else if (data && Array.isArray(data.regions)) { + list = data.regions; + } else { + console.error('Invalid /console/regions JSON shape'); + return null; + } + + setRegionHosts(list); + return { total: list.length, regions: list }; + } catch (error) { + console.error('Failed to load self-hosted regions catalog', error); + return null; + } +} + /** * Loads available regions for a given organization. * + * Cloud: organizations.listRegions API. + * Self-hosted multi-region: GET /console/regions (nginx/ConfigMap JSON). + * * Prevents unnecessary API calls if the regions are already loaded for the same organization. */ export async function loadAvailableRegions(orgId: string, force: boolean = false): Promise { - if (!isCloud || !orgId) return; + if (!orgId) return; try { const storedRegions = get(regions); - if (storedRegions.regions && lastLoadedOrganization === orgId && !force) { + if (storedRegions.regions?.length && lastLoadedOrganization === orgId && !force) { // already loaded for this organization, fast path return. return; } - const availableRegions = await sdk.forConsole.organizations.listRegions({ - organizationId: orgId - }); + if (isCloud) { + const availableRegions = await sdk.forConsole.organizations.listRegions({ + organizationId: orgId + }); + regions.set(availableRegions); + lastLoadedOrganization = orgId; + return; + } - regions.set(availableRegions); - lastLoadedOrganization = orgId; + if (isMultiRegion) { + const catalog = await loadSelfHostedRegions(); + if (catalog) { + regions.set(catalog); + lastLoadedOrganization = orgId; + } + } } catch (error) { console.error(`Failed to load regions for teamId: ${orgId}`, error); } From 3c2c405c670d87422b602a37c55056c7cd7a4446 Mon Sep 17 00:00:00 2001 From: Ibaraki Douji Date: Thu, 6 Aug 2026 22:36:27 +0200 Subject: [PATCH 2/5] fix(env): update .env.example for self-hosted configuration and clarify API endpoint usage --- .env.example | 4 +++- src/lib/helpers/apiEndpoint.ts | 20 +++++++++++++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index ef47c6acb5..5c5e5427aa 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,9 @@ 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 for self-hosted so the SPA uses the current page hostname + /v1. +# Set explicitly only when the API is on a different origin (unusual for SH). +PUBLIC_APPWRITE_ENDPOINT= PUBLIC_STRIPE_KEY= PUBLIC_GROWTH_ENDPOINT= PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=true diff --git a/src/lib/helpers/apiEndpoint.ts b/src/lib/helpers/apiEndpoint.ts index 280c50707f..b2773d8af9 100644 --- a/src/lib/helpers/apiEndpoint.ts +++ b/src/lib/helpers/apiEndpoint.ts @@ -13,6 +13,7 @@ import { 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[] = [ @@ -67,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, @@ -81,10 +85,12 @@ export function buildRegionalV1Endpoint( return `${protocol}//${hostname}/v1`; } - // Self-hosted: optional hostname/endpoint from /console/regions catalog - const override = resolveRegionV1Endpoint(protocol, region); - if (override) { - return override; + if (!isCloud) { + const override = resolveRegionV1Endpoint(protocol, region); + if (override) { + return override; + } + return `${protocol}//${hostname}/v1`; } const subdomain = getRegionSubdomain(region); From ed29aa2498e2fb34ac2efb10c7ab71cf9b6b0fb2 Mon Sep 17 00:00:00 2001 From: Ibaraki Douji Date: Thu, 6 Aug 2026 23:58:40 +0200 Subject: [PATCH 3/5] feat(regions): add ensureSelfHostedRegions function to manage region loading in self-hosted environments --- .env.example | 7 ++++--- src/routes/(console)/+layout.ts | 4 ++++ src/routes/(console)/regions.ts | 37 ++++++++++++++++++++++++++++++--- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 5c5e5427aa..cbe7c09763 100644 --- a/.env.example +++ b/.env.example @@ -2,9 +2,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 -# Leave empty for self-hosted so the SPA uses the current page hostname + /v1. -# Set explicitly only when the API is on a different origin (unusual for SH). -PUBLIC_APPWRITE_ENDPOINT= +# Local `bun dev`: point at your API (see AGENTS.md). +# Production/self-hosted Docker images should leave this unset so the SPA uses the +# current page hostname + /v1 (no per-environment rebuild). +PUBLIC_APPWRITE_ENDPOINT=http://localhost/v1 PUBLIC_STRIPE_KEY= PUBLIC_GROWTH_ENDPOINT= PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=true diff --git a/src/routes/(console)/+layout.ts b/src/routes/(console)/+layout.ts index b191a64364..1a8910d34b 100644 --- a/src/routes/(console)/+layout.ts +++ b/src/routes/(console)/+layout.ts @@ -11,8 +11,12 @@ import { syncServerTime } from '$lib/helpers/fingerprint'; import { redirect } from '@sveltejs/kit'; import { resolve } from '$app/paths'; import { isVerifyEmailRedirectError } from '$lib/helpers/emailVerification'; +import { ensureSelfHostedRegions } from '$routes/(console)/regions'; export const load: LayoutLoad = async ({ depends, parent, url }) => { + // 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)/regions.ts b/src/routes/(console)/regions.ts index 9f7d468ea5..2f56b01fe1 100644 --- a/src/routes/(console)/regions.ts +++ b/src/routes/(console)/regions.ts @@ -7,6 +7,7 @@ import { setRegionHosts, type ConsoleRegionWithHost } from '$lib/helpers/regionH import type { Models } from '@appwrite.io/console'; let lastLoadedOrganization = null; +let selfHostedRegionsPromise: Promise | null = null; async function loadSelfHostedRegions(): Promise { try { @@ -35,6 +36,34 @@ async function loadSelfHostedRegions(): Promise } } +/** + * Ensure self-hosted `/console/regions` hosts are loaded before the first regional SDK call. + * No-op on Cloud or when multi-region is disabled. Idempotent (shared in-flight promise). + */ +export async function ensureSelfHostedRegions(): Promise { + if (isCloud || !isMultiRegion) return; + + const stored = get(regions); + if (stored.regions?.length) { + setRegionHosts(stored.regions as ConsoleRegionWithHost[]); + return; + } + + if (!selfHostedRegionsPromise) { + selfHostedRegionsPromise = loadSelfHostedRegions().finally(() => { + // Allow retry after a failed fetch on the next navigation. + if (!get(regions).regions?.length) { + selfHostedRegionsPromise = null; + } + }); + } + + const catalog = await selfHostedRegionsPromise; + if (catalog) { + regions.set(catalog); + } +} + /** * Loads available regions for a given organization. * @@ -64,9 +93,11 @@ export async function loadAvailableRegions(orgId: string, force: boolean = false } if (isMultiRegion) { - const catalog = await loadSelfHostedRegions(); - if (catalog) { - regions.set(catalog); + if (force) { + selfHostedRegionsPromise = null; + } + await ensureSelfHostedRegions(); + if (get(regions).regions?.length) { lastLoadedOrganization = orgId; } } From dbfd407da1d9bb6587ba7582db869e0932f44b10 Mon Sep 17 00:00:00 2001 From: Ibaraki Douji Date: Fri, 7 Aug 2026 10:39:03 +0200 Subject: [PATCH 4/5] fix(regions): improve error handling in loadSelfHostedRegions function and ensure proper region loading --- src/routes/(console)/regions.ts | 43 +++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/src/routes/(console)/regions.ts b/src/routes/(console)/regions.ts index 2f56b01fe1..4cac9087a3 100644 --- a/src/routes/(console)/regions.ts +++ b/src/routes/(console)/regions.ts @@ -1,5 +1,6 @@ import { get } from 'svelte/store'; import { base } from '$app/paths'; +import { error } from '@sveltejs/kit'; import { sdk } from '$lib/stores/sdk'; import { isCloud, isMultiRegion } from '$lib/system'; import { regions } from '$lib/stores/organization'; @@ -7,14 +8,13 @@ import { setRegionHosts, type ConsoleRegionWithHost } from '$lib/helpers/regionH import type { Models } from '@appwrite.io/console'; let lastLoadedOrganization = null; -let selfHostedRegionsPromise: Promise | null = null; +let selfHostedRegionsPromise: Promise | null = null; -async function loadSelfHostedRegions(): Promise { +async function loadSelfHostedRegions(): Promise { try { const res = await fetch(`${base}/regions`, { cache: 'no-store' }); if (!res.ok) { - console.error(`Failed to fetch ${base}/regions: ${res.status}`); - return null; + throw error(503, `Failed to fetch ${base}/regions: ${res.status}`); } const data = await res.json(); @@ -24,21 +24,29 @@ async function loadSelfHostedRegions(): Promise } else if (data && Array.isArray(data.regions)) { list = data.regions; } else { - console.error('Invalid /console/regions JSON shape'); - return null; + throw error(503, 'Invalid /console/regions JSON shape'); + } + + if (!list.length) { + throw error(503, 'Self-hosted regions catalog is empty'); } setRegionHosts(list); return { total: list.length, regions: list }; - } catch (error) { - console.error('Failed to load self-hosted regions catalog', error); - return null; + } catch (err) { + // Preserve SvelteKit HttpError from this loader; wrap other failures. + if (err && typeof err === 'object' && 'status' in err && 'body' in err) { + throw err; + } + console.error('Failed to load self-hosted regions catalog', err); + throw error(503, 'Failed to load self-hosted regions catalog'); } } /** * Ensure self-hosted `/console/regions` hosts are loaded before the first regional SDK call. * No-op on Cloud or when multi-region is disabled. Idempotent (shared in-flight promise). + * Hard-fails on catalog load errors so regional traffic cannot silently fall back to apex `/v1`. */ export async function ensureSelfHostedRegions(): Promise { if (isCloud || !isMultiRegion) return; @@ -58,10 +66,7 @@ export async function ensureSelfHostedRegions(): Promise { }); } - const catalog = await selfHostedRegionsPromise; - if (catalog) { - regions.set(catalog); - } + regions.set(await selfHostedRegionsPromise); } /** @@ -97,11 +102,13 @@ export async function loadAvailableRegions(orgId: string, force: boolean = false selfHostedRegionsPromise = null; } await ensureSelfHostedRegions(); - if (get(regions).regions?.length) { - lastLoadedOrganization = orgId; - } + lastLoadedOrganization = orgId; + } + } catch (err) { + console.error(`Failed to load regions for teamId: ${orgId}`, err); + // Multi-region SH must not continue with an empty host map (apex fallback). + if (isMultiRegion && !isCloud) { + throw err; } - } catch (error) { - console.error(`Failed to load regions for teamId: ${orgId}`, error); } } From 24479759dfede55d33aa2819f6a69cdd574eb037 Mon Sep 17 00:00:00 2001 From: Ibaraki Douji Date: Fri, 7 Aug 2026 10:46:16 +0200 Subject: [PATCH 5/5] chore(env): Revert PUBLIC_APPWRITE_ENDPOINT as greptile complain in both cases --- .env.example | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index cbe7c09763..e5222c8ac8 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,9 @@ 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 -# Local `bun dev`: point at your API (see AGENTS.md). -# Production/self-hosted Docker images should leave this unset so the SPA uses the -# current page hostname + /v1 (no per-environment rebuild). -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