Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=true
10 changes: 10 additions & 0 deletions docker/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
19 changes: 16 additions & 3 deletions src/lib/helpers/apiEndpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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,
Expand All @@ -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`;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

const subdomain = getRegionSubdomain(region);
if (!subdomain) {
return `${protocol}//${hostname}/v1`;
Expand Down
6 changes: 2 additions & 4 deletions src/lib/helpers/project.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
61 changes: 61 additions & 0 deletions src/lib/helpers/regionHosts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { expect, test, beforeEach } from 'vitest';
import {
setRegionHosts,
resolveRegionV1Endpoint,
type ConsoleRegionWithHost
} from '$lib/helpers/regionHosts';

function region(partial: Partial<ConsoleRegionWithHost> & { $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');
});
54 changes: 54 additions & 0 deletions src/lib/helpers/regionHosts.ts
Original file line number Diff line number Diff line change
@@ -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<string, RegionHostInfo>();

/** 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;
}
4 changes: 2 additions & 2 deletions src/lib/layout/createProject.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -80,7 +80,7 @@
<CustomId bind:show={showCustomId} name="Project" isProject bind:id />
</Layout.Stack>

{#if isCloud && regions.length > 0}
{#if (isCloud || isMultiRegion) && regions.length > 0}
<Layout.Stack gap="xs">
<Input.Select
disabled={projectsLimited}
Expand Down
1 change: 1 addition & 0 deletions src/lib/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const ENV = {
export const MODE = VARS.CONSOLE_MODE === Mode.CLOUD ? Mode.CLOUD : Mode.SELF_HOSTED;
export const isCloud = MODE === Mode.CLOUD;
export const isSelfHosted = MODE !== Mode.CLOUD;
export const isMultiRegion = env.PUBLIC_APPWRITE_MULTI_REGION === 'true';
export const isDev = ENV.DEV;
export const isProd = ENV.PROD;
export const hasStripePublicKey = !!VARS.PUBLIC_STRIPE_KEY;
Expand Down
4 changes: 4 additions & 0 deletions src/routes/(console)/+layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/routes/(console)/onboarding/create-project/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
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';
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions src/routes/(console)/organization-[organization]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -291,7 +291,7 @@
{/if}

<svelte:fragment slot="icons">
{#if isCloud && $regionsStore?.regions}
{#if (isCloud || isMultiRegion) && $regionsStore?.regions}
{@const region = findRegion(project)}
<Typography.Text>{region.name}</Typography.Text>
{/if}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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',
Expand All @@ -55,6 +72,18 @@
<Modal title="Create project" {error} onSubmit={create} bind:show>
<Layout.Stack gap="l">
<InputText id="name" label="Name" bind:value={name} required autofocus={true} />
{#if isMultiRegion && regionOptions.length > 0}
<Layout.Stack gap="xs">
<InputSelect
id="region"
label="Region"
required
bind:value={region}
options={regionOptions}
placeholder="Select a region" />
<Typography.Text>Region cannot be changed after creation</Typography.Text>
</Layout.Stack>
{/if}
{#if !showCustomId}
<span>
<Tag size="s" on:click={() => (showCustomId = !showCustomId)}>
Expand Down
Loading