select spacetime server

This commit is contained in:
2026-04-04 13:44:37 -04:00
parent a0c3e7a1bc
commit 4eec1e0e09
8 changed files with 244 additions and 33 deletions
+1
View File
@@ -8,6 +8,7 @@
</head>
<body>
<div id="root"></div>
<script src="/config.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+3 -2
View File
@@ -10,10 +10,11 @@
"lint": "eslint . && prettier . --check --ignore-path ../../.prettierignore",
"preview": "vite preview",
"test": "vitest run",
"generate": "cargo run -p gen-bindings -- --out-dir src/module_bindings --module-path spacetimedb && prettier --write src/module_bindings",
"spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --module-path spacetimedb",
"spacetime:publish:local": "spacetime publish --module-path spacetimedb --server local",
"spacetime:publish": "spacetime publish --module-path spacetimedb --server maincloud"
"spacetime:publish": "spacetime publish --module-path spacetimedb --server maincloud",
"deploy:local": "docker compose -f docker-compose.local.yml up --build",
"deploy:maincloud": "docker compose -f docker-compose.maincloud.yml up --build"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^7.2.0",
+15 -8
View File
@@ -1,21 +1,28 @@
<script lang="ts">
import { AuthGate } from "./auth";
import { ChatContainer } from "./chat";
import SpacetimeProvider from "./SpacetimeProvider.svelte";
import "./App.css";
let reconnectKey = $state(0);
let showServerSettings = $state(false);
function handleReconnect() {
console.log("App: Triggering reconnection...");
reconnectKey++;
showServerSettings = false;
}
function handleToggleServerSettings(val: boolean) {
console.log("App: Setting showServerSettings to", val);
showServerSettings = val;
}
</script>
{#key reconnectKey}
<SpacetimeProvider onReconnectTrigger={handleReconnect}>
<AuthGate>
<ChatContainer />
</AuthGate>
</SpacetimeProvider>
{/key}
<AuthGate
reconnect={handleReconnect}
{showServerSettings}
onToggleServerSettings={handleToggleServerSettings}
{reconnectKey}
>
<ChatContainer onShowServerSettings={() => handleToggleServerSettings(true)} />
</AuthGate>
+141 -7
View File
@@ -1,16 +1,47 @@
<script lang="ts">
import { onMount } from "svelte";
import { auth } from "./auth.svelte";
import { TOKEN_KEY } from "../config";
import { HOST_KEY, DB_NAME_KEY, getEnv, TokenStore, getStdbHost, getStdbDbName } from "../config";
import SpacetimeProvider from "../SpacetimeProvider.svelte";
let { children } = $props<{ children: any }>();
let { children, reconnect, showServerSettings, onToggleServerSettings, reconnectKey } = $props<{
children: any,
reconnect: () => void,
showServerSettings: boolean,
onToggleServerSettings: (val: boolean) => void,
reconnectKey: number
}>();
let authError = $state<string | null>(null);
let hasStoredToken = $state(false);
let isGuest = $state(false);
let stdbHost = $state("");
let stdbDbName = $state("");
const MAINCLOUD_URI = "wss://maincloud.spacetimedb.com";
let isMaincloud = $state(true);
onMount(() => {
hasStoredToken = !!localStorage.getItem(TOKEN_KEY);
stdbHost = getStdbHost();
stdbDbName = getStdbDbName();
isMaincloud = stdbHost === MAINCLOUD_URI || stdbHost === "";
if (isMaincloud) stdbHost = MAINCLOUD_URI;
});
$effect(() => {
if (isMaincloud) {
stdbHost = MAINCLOUD_URI;
}
});
const hasStoredToken = $derived(!!TokenStore.get(stdbHost, stdbDbName));
$effect(() => {
if (stdbHost) localStorage.setItem(HOST_KEY, stdbHost);
});
$effect(() => {
if (stdbDbName) localStorage.setItem(DB_NAME_KEY, stdbDbName);
});
function handleAuthError(error: string | null) {
@@ -22,6 +53,7 @@
new URLSearchParams(window.location.search).has("bypass_auth");
const isAuthenticated = $derived(auth.isAuthenticated || hasStoredToken || isGuest || isBypassEnabled);
const shouldShowContent = $derived(isAuthenticated && !showServerSettings);
$effect(() => {
console.log("AuthGate: auth.isLoading:", auth.isLoading);
@@ -36,8 +68,12 @@
<div class="login-screen">
<h1>Loading Authentication...</h1>
</div>
{:else if isAuthenticated}
{@render children()}
{:else if shouldShowContent}
{#key reconnectKey}
<SpacetimeProvider onReconnectTrigger={reconnect}>
{@render children()}
</SpacetimeProvider>
{/key}
{:else}
<div class="login-screen">
<div class="login-card">
@@ -62,9 +98,107 @@
class="btn-secondary"
style="width: 100%"
>
Continue as a guest
{hasStoredToken ? "Reconnect as last user" : "Continue as a guest"}
</button>
</div>
<div class="server-settings-section" style="margin-top: 24px; padding-top: 24px; border-top: 1px solid var(--background-modifier-accent); width: 100%">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h3 style="font-size: 0.8rem; color: var(--text-muted); text-transform: uppercase; margin: 0;">Server Settings</h3>
<button
onclick={() => {
reconnect();
onToggleServerSettings(false);
}}
class="btn-ghost"
style="padding: 2px 8px; font-size: 0.7rem; text-transform: uppercase; color: var(--brand);"
>
Apply & Reconnect
</button>
</div>
<div style="display: flex; flex-direction: column; gap: 16px;">
<div class="form-group" style="display: flex; flex-direction: column; gap: 4px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<label for="stdb-host" style="font-size: 0.7rem; font-weight: bold; color: var(--text-muted); text-transform: uppercase;">SpacetimeDB Host</label>
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 0.7rem; color: var(--text-muted); text-transform: uppercase; font-weight: bold;">Maincloud</span>
<label class="ios-switch">
<input type="checkbox" bind:checked={isMaincloud} />
<span class="slider"></span>
</label>
</div>
</div>
<input
id="stdb-host"
type="text"
bind:value={stdbHost}
disabled={isMaincloud}
placeholder="wss://maincloud.spacetimedb.com"
style="background-color: var(--background-tertiary); color: {isMaincloud ? 'var(--text-muted)' : 'var(--text-normal)'}; border: 1px solid var(--background-modifier-accent); border-radius: 4px; padding: 10px; cursor: {isMaincloud ? 'not-allowed' : 'text'}; transition: all 0.2s;"
/>
</div>
<div class="form-group" style="display: flex; flex-direction: column; gap: 4px;">
<label for="stdb-db" style="font-size: 0.7rem; font-weight: bold; color: var(--text-muted); text-transform: uppercase;">Database Name</label>
<input
id="stdb-db"
type="text"
bind:value={stdbDbName}
placeholder="my-spacetime-app-jdhdg"
style="background-color: var(--background-tertiary); color: var(--text-normal); border: 1px solid var(--background-modifier-accent); border-radius: 4px; padding: 10px;"
/>
</div>
</div>
</div>
</div>
</div>
{/if}
<style>
.ios-switch {
position: relative;
display: inline-block;
width: 34px;
height: 20px;
}
.ios-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #3f4147;
transition: .4s;
border-radius: 20px;
}
.slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
background-color: white;
transition: .4s;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
input:checked + .slider {
background-color: var(--status-positive);
}
input:checked + .slider:before {
transform: translateX(14px);
}
</style>
+8 -4
View File
@@ -3,12 +3,12 @@ import {
type User,
type UserManagerSettings,
} from "oidc-client-ts";
import { getEnv } from "../config";
// OIDC Configuration - User should replace these with their own provider values
export const oidcConfig: UserManagerSettings = {
authority:
import.meta.env.VITE_OIDC_AUTHORITY ?? "https://accounts.google.com",
client_id: import.meta.env.VITE_OIDC_CLIENT_ID ?? "REPLACE_ME",
authority: getEnv("VITE_OIDC_AUTHORITY", "https://accounts.google.com"),
client_id: getEnv("VITE_OIDC_CLIENT_ID", "REPLACE_ME"),
redirect_uri: window.location.origin,
scope: "openid profile email",
response_type: "code",
@@ -98,12 +98,14 @@ class AuthStore {
this.#isLoading = true;
try {
// Clear all potential SpacetimeDB tokens from local storage
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.includes("auth_token")) {
localStorage.removeItem(key);
keysToRemove.push(key);
}
}
keysToRemove.forEach(key => localStorage.removeItem(key));
if (this.#user) {
await this.#userManager.signoutRedirect();
@@ -113,6 +115,8 @@ class AuthStore {
}
} catch (error) {
console.error("Logout error:", error);
// Fallback reload if signout fails
window.location.reload();
} finally {
this.#isLoading = false;
}
+3 -1
View File
@@ -15,6 +15,8 @@
import SettingsPanel from "./components/SettingsPanel.svelte";
import ImageViewer from "./components/ImageViewer.svelte";
let { onShowServerSettings } = $props<{ onShowServerSettings: () => void }>();
const spacetime = useSpacetimeDB();
// identity is guaranteed to be non-null here because of the guard in App.svelte
const identity = $derived($spacetime.identity!);
@@ -45,7 +47,7 @@
<div class="chat-container" onclick={handleGlobalClick}>
<div class="left-sidebar-wrapper">
<div class="left-sidebar-top">
<ServerList />
<ServerList {onShowServerSettings} />
<div class="sidebar-container">
<ChannelList />
+18
View File
@@ -3,6 +3,8 @@
import type { ChatService } from "../services/chat.svelte";
const chat = getContext<ChatService>("chat");
let { onShowServerSettings } = $props<{ onShowServerSettings: () => void }>();
</script>
<div class="server-list">
@@ -33,6 +35,18 @@
<i class="fas fa-search"></i>
</button>
<div style="margin-top: auto; display: flex; flex-direction: column; gap: 8px; align-items: center; width: 100%;">
<div class="sidebar-separator" style="width: 32px; margin: 0; background-color: var(--background-modifier-accent); height: 2px;"></div>
<button
class="server-icon"
onclick={onShowServerSettings}
title="Switch Server Configuration"
style="color: var(--brand); margin-bottom: 12px;"
>
<i class="fas fa-network-wired"></i>
</button>
</div>
<!-- Create Server Modal -->
{#if chat.showCreateServerModal}
<div
@@ -98,6 +112,10 @@
align-items: center;
justify-content: center;
transition: all 0.2s ease-in-out;
width: 48px;
height: 48px;
border-radius: 50%;
flex-shrink: 0;
}
.server-icon:hover {
+55 -11
View File
@@ -2,11 +2,46 @@
import { DbConnection } from "./module_bindings/index.ts";
import { auth } from "./auth";
export const HOST =
import.meta.env.VITE_SPACETIMEDB_HOST ?? "wss://maincloud.spacetimedb.com";
export const DB_NAME =
import.meta.env.VITE_SPACETIMEDB_DB_NAME ?? "my-spacetime-app-jdhdg";
export const TOKEN_KEY = `${HOST}/${DB_NAME}/auth_token`;
export const getEnv = (key: string, defaultValue: string) => {
const windowEnv = (window as any).__ENV__;
if (windowEnv && windowEnv[key] && windowEnv[key] !== "") {
return windowEnv[key];
}
if (import.meta.env[key] && import.meta.env[key] !== "") {
return import.meta.env[key];
}
return defaultValue;
};
export const HOST_KEY = "stdb_host";
export const DB_NAME_KEY = "stdb_db_name";
const normalizeHost = (host: string) => {
try {
const url = new URL(host.includes("://") ? host : `wss://${host}`);
return url.origin.replace(/^http/, 'ws'); // Ensure ws/wss
} catch (e) {
return host.trim().replace(/\/+$/, ""); // Fallback
}
};
export const TokenStore = {
get: (host: string, dbName: string) => {
const key = `${normalizeHost(host)}/${dbName}/auth_token`;
return localStorage.getItem(key);
},
set: (host: string, dbName: string, token: string) => {
const key = `${normalizeHost(host)}/${dbName}/auth_token`;
localStorage.setItem(key, token);
},
clear: (host: string, dbName: string) => {
const key = `${normalizeHost(host)}/${dbName}/auth_token`;
localStorage.removeItem(key);
}
};
export const getStdbHost = () => localStorage.getItem(HOST_KEY) || getEnv("VITE_SPACETIMEDB_HOST", "wss://maincloud.spacetimedb.com");
export const getStdbDbName = () => localStorage.getItem(DB_NAME_KEY) || getEnv("VITE_SPACETIMEDB_DB_NAME", "my-spacetime-app-jdhdg");
let _connection: DbConnection | null = null;
export const getConnection = () => _connection;
@@ -15,14 +50,18 @@ class ConnectionManager {
#retryCount = 0;
#reconnectTimeout: any = null;
#onReconnectTrigger: () => void;
#host: string;
#dbName: string;
constructor(onReconnectTrigger: () => void) {
constructor(host: string, dbName: string, onReconnectTrigger: () => void) {
this.#host = host;
this.#dbName = dbName;
this.#onReconnectTrigger = onReconnectTrigger;
}
onConnect = (conn: DbConnection, identity: any, token: string) => {
_connection = conn;
localStorage.setItem(TOKEN_KEY, token);
TokenStore.set(this.#host, this.#dbName, token);
console.log(
"Connected to SpacetimeDB with identity:",
identity.toHexString(),
@@ -63,15 +102,20 @@ class ConnectionManager {
}
export const connectionBuilder = (onReconnectTrigger: () => void) => {
const manager = new ConnectionManager(onReconnectTrigger);
const host = getStdbHost();
const dbName = getStdbDbName();
console.log(`Connecting to SpacetimeDB: ${host}/${dbName}`);
const manager = new ConnectionManager(host, dbName, onReconnectTrigger);
const builder = DbConnection.builder()
.withUri(HOST)
.withDatabaseName(DB_NAME)
.withUri(host)
.withDatabaseName(dbName)
.onConnect(manager.onConnect)
.onDisconnect(manager.onDisconnect)
.onConnectError(manager.onConnectError);
const storedToken = localStorage.getItem(TOKEN_KEY);
const storedToken = TokenStore.get(host, dbName);
if (auth.isAuthenticated && auth.user?.id_token) {
console.log("SpacetimeDBWrapper: Connecting with OIDC token");