diff --git a/hub-client/src/App.tsx b/hub-client/src/App.tsx index 554ea94e4..ee2421d38 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -38,6 +38,7 @@ import { useAuth } from './hooks/useAuth'; import { useAuthProbe } from './hooks/useAuthProbe'; import { resolveActorId as resolveActorIdRequest } from './services/authService'; import type { Route, ShareRoute, LinkProjectSetRoute } from './utils/routing'; +import { resolveSyncServerUrl } from './utils/routing'; import './App.css'; /** @@ -68,7 +69,7 @@ async function connectAndLoadContents( // time when the connection is genuinely slow — exactly the CI case we want to // wait out. Tree-shaken in production, so no offline-first UX change there. const peerTimeoutMs = import.meta.env.VITE_E2E === '1' ? 15000 : undefined; - const files = await connect(syncServer, indexDocId, actorId, screenName, color, peerTimeoutMs); + const files = await connect(resolveSyncServerUrl(syncServer), indexDocId, actorId, screenName, color, peerTimeoutMs); const contents = new Map(); for (const file of files) { const content = getFileContent(file.path); @@ -524,8 +525,12 @@ function App() { // Create the Automerge documents. The resolveActorId callback is // called after the index doc is created (to derive the HMAC actor // ID from the indexDocId) but before any file docs are written. + // + // Resolve only the runtime connection value (the WS adapter needs an + // absolute ws(s):// URL); the portable `syncServer` is what we store + // and share below, so it stays origin-independent under a subpath. const result = await createNewProject({ - syncServer, + syncServer: resolveSyncServerUrl(syncServer), files, }, undefined, screenName, cursorColor, resolveActorId); diff --git a/hub-client/src/components/auth/LoginScreen.test.tsx b/hub-client/src/components/auth/LoginScreen.test.tsx index cbb67c4f1..868a27d61 100644 --- a/hub-client/src/components/auth/LoginScreen.test.tsx +++ b/hub-client/src/components/auth/LoginScreen.test.tsx @@ -4,7 +4,7 @@ * @vitest-environment jsdom */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, cleanup } from '@testing-library/react'; import type { ReactNode } from 'react'; @@ -18,7 +18,10 @@ beforeEach(() => { mock = createMockAuthProvider(); }); -afterEach(cleanup); +afterEach(() => { + cleanup(); + vi.unstubAllEnvs(); +}); function withProvider(children: ReactNode) { return ( @@ -38,6 +41,13 @@ describe('LoginScreen', () => { expect(mock.lastLoginUri).toBe(window.location.origin + '/auth/callback'); }); + it('prefixes the callback with the hub base path under a subpath mount', () => { + vi.stubEnv('VITE_HUB_BASE_PATH', '/subpath'); + render(withProvider()); + + expect(mock.lastLoginUri).toBe(window.location.origin + '/subpath/auth/callback'); + }); + it('renders the default copy when error is false/absent', () => { render(withProvider()); expect(screen.getByText(/Sign in with Google to continue/i)).toBeTruthy(); diff --git a/hub-client/src/components/auth/LoginScreen.tsx b/hub-client/src/components/auth/LoginScreen.tsx index 646feeb5c..0bebde286 100644 --- a/hub-client/src/components/auth/LoginScreen.tsx +++ b/hub-client/src/components/auth/LoginScreen.tsx @@ -12,6 +12,7 @@ */ import { useAuthProvider } from '../../auth/AuthProvider'; +import { hubPath } from '../../utils/routing'; export function LoginScreen({ error, message }: { error?: boolean; message?: string }) { const provider = useAuthProvider(); @@ -35,7 +36,7 @@ export function LoginScreen({ error, message }: { error?: boolean; message?: str

)} diff --git a/hub-client/src/services/authService.test.ts b/hub-client/src/services/authService.test.ts index 5c5f716d5..a7c13734f 100644 --- a/hub-client/src/services/authService.test.ts +++ b/hub-client/src/services/authService.test.ts @@ -19,6 +19,44 @@ describe('authService', () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + // ── hub base path (subpath mount) ─────────────────────────── + + describe('VITE_HUB_BASE_PATH', () => { + it('prefixes auth requests with the configured mount base', async () => { + vi.stubEnv('VITE_HUB_BASE_PATH', '/subpath'); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ email: 'admin', name: 'admin', picture: null }), + } as Response); + + await fetchAuthMe(); + expect(fetch).toHaveBeenCalledWith('/subpath/auth/me', { + credentials: 'same-origin', + }); + + await fetchActorId('proj-1'); + expect(fetch).toHaveBeenCalledWith( + '/subpath/auth/actor?project=proj-1', + { credentials: 'same-origin' }, + ); + }); + + it('leaves paths origin-absolute when unset (dev / standalone)', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ email: 'a@b.com', name: null, picture: null }), + } as Response); + + await fetchAuthMe(); + expect(fetch).toHaveBeenCalledWith('/auth/me', { + credentials: 'same-origin', + }); + }); }); // ── fetchAuthMe ───────────────────────────────────────────── diff --git a/hub-client/src/services/authService.ts b/hub-client/src/services/authService.ts index 0f363f486..52ba6b980 100644 --- a/hub-client/src/services/authService.ts +++ b/hub-client/src/services/authService.ts @@ -8,6 +8,8 @@ * `AuthProvider`'s concern, not this module's. */ +import { hubPath } from '../utils/routing'; + /** User info returned by GET /auth/me. */ export interface AuthState { email: string; @@ -28,7 +30,7 @@ interface AuthMeResponse { /** Fetch user info from the server. Returns null on 401 (not authenticated). */ export async function fetchAuthMe(): Promise { - const res = await fetch('/auth/me', { credentials: 'same-origin' }); + const res = await fetch(hubPath('/auth/me'), { credentials: 'same-origin' }); if (res.status === 401 || res.status === 403) return null; if (!res.ok) throw new Error(`/auth/me failed: ${res.status}`); const data = await res.json() as AuthMeResponse; @@ -56,7 +58,7 @@ interface AuthActorResponse { */ export async function fetchActorId(projectId: string): Promise { const res = await fetch( - `/auth/actor?project=${encodeURIComponent(projectId)}`, + hubPath(`/auth/actor?project=${encodeURIComponent(projectId)}`), { credentials: 'same-origin' }, ); if (res.status === 401 || res.status === 403) return null; @@ -93,7 +95,7 @@ export async function resolveActorId( /** Clear the auth cookie server-side. */ export async function logout(): Promise { - await fetch('/auth/logout', { + await fetch(hubPath('/auth/logout'), { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' }, @@ -105,7 +107,7 @@ export async function logout(): Promise { * Returns the updated user info on success, null on auth failure. */ export async function refreshToken(credential: string): Promise { - const res = await fetch('/auth/refresh', { + const res = await fetch(hubPath('/auth/refresh'), { method: 'POST', credentials: 'same-origin', headers: { diff --git a/hub-client/src/services/projectSetService.ts b/hub-client/src/services/projectSetService.ts index f958c6c73..7c2a3f7af 100644 --- a/hub-client/src/services/projectSetService.ts +++ b/hub-client/src/services/projectSetService.ts @@ -14,6 +14,7 @@ import type { DocHandle, DocumentId } from '@automerge/automerge-repo'; import { from as automergeFrom, save as automergeSerialize } from '@automerge/automerge'; import { BrowserWebSocketClientAdapter } from '@automerge/automerge-repo-network-websocket'; import { IndexedDBStorageAdapter } from '@automerge/automerge-repo-storage-indexeddb'; +import { resolveSyncServerUrl } from '../utils/routing'; import type { ProjectSetDocument, @@ -124,7 +125,7 @@ export async function connect( ): Promise { await disconnect(); - wsAdapter = new BrowserWebSocketClientAdapter(syncServerUrl); + wsAdapter = new BrowserWebSocketClientAdapter(resolveSyncServerUrl(syncServerUrl)); repo = new Repo({ network: [wsAdapter], storage: new IndexedDBStorageAdapter(), @@ -192,7 +193,7 @@ export async function createProjectSet( ): Promise { await disconnect(); - wsAdapter = new BrowserWebSocketClientAdapter(syncServerUrl); + wsAdapter = new BrowserWebSocketClientAdapter(resolveSyncServerUrl(syncServerUrl)); repo = new Repo({ network: [wsAdapter], storage: new IndexedDBStorageAdapter(), diff --git a/hub-client/src/utils/routing.test.ts b/hub-client/src/utils/routing.test.ts index a1786d5e1..656ecccb2 100644 --- a/hub-client/src/utils/routing.test.ts +++ b/hub-client/src/utils/routing.test.ts @@ -1,7 +1,7 @@ /** * Tests for URL routing utilities. */ -import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; import { parseHashRoute, buildHashRoute, @@ -11,6 +11,8 @@ import { sameFile, savePreAuthHash, restorePreAuthHash, + resolveSyncServerUrl, + hubPath, type Route, type ShareRoute, type LinkProjectSetRoute, @@ -575,6 +577,80 @@ describe('sameFile', () => { }); }); +// ── resolveSyncServerUrl ───────────────────────────────────────── + +describe('resolveSyncServerUrl', () => { + const originalWindow = globalThis.window; + + afterEach(() => { + // @ts-expect-error - restoring window + globalThis.window = originalWindow; + }); + + function mockLocation(protocol: string, host: string) { + // @ts-expect-error - mocking window in node environment + globalThis.window = { + location: { protocol, host }, + }; + } + + it('resolves a relative path to wss:// on an https origin', () => { + mockLocation('https:', 'hub.example.com'); + expect(resolveSyncServerUrl('/subpath/ws')).toBe( + 'wss://hub.example.com/subpath/ws' + ); + }); + + it('resolves a relative path to ws:// on an http origin', () => { + mockLocation('http:', 'localhost:3939'); + expect(resolveSyncServerUrl('/subpath/ws')).toBe( + 'ws://localhost:3939/subpath/ws' + ); + }); + + it('leaves an absolute wss:// URL unchanged', () => { + mockLocation('https:', 'hub.example.com'); + expect(resolveSyncServerUrl('wss://sync.automerge.org')).toBe( + 'wss://sync.automerge.org' + ); + }); + + it('leaves an absolute ws:// URL unchanged', () => { + mockLocation('http:', 'localhost:3939'); + expect(resolveSyncServerUrl('ws://localhost:3000')).toBe( + 'ws://localhost:3000' + ); + }); + + it('leaves an absolute https:// URL unchanged', () => { + mockLocation('https:', 'hub.example.com'); + expect(resolveSyncServerUrl('https://sync.example.com')).toBe( + 'https://sync.example.com' + ); + }); +}); + +// ── hubPath ────────────────────────────────────────────────────── +// +// Single source of truth for the subpath mount: prefixes auth REST +// calls and derives the sync-server default (`hubPath('/ws')`). + +describe('hubPath', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('is a no-op when no base path is set (served from the hub origin)', () => { + expect(hubPath('/auth/me')).toBe('/auth/me'); + }); + + it('prefixes the configured mount base (subpath deployment)', () => { + vi.stubEnv('VITE_HUB_BASE_PATH', '/subpath'); + expect(hubPath('/auth/me')).toBe('/subpath/auth/me'); + expect(hubPath('/ws')).toBe('/subpath/ws'); + }); +}); + // ── Pre-Auth Hash Preservation ────────────────────────────────── describe('savePreAuthHash / restorePreAuthHash', () => { diff --git a/hub-client/src/utils/routing.ts b/hub-client/src/utils/routing.ts index 0e8b82e5c..67c239861 100644 --- a/hub-client/src/utils/routing.ts +++ b/hub-client/src/utils/routing.ts @@ -21,10 +21,40 @@ * sensitive indexDocId from appearing in browser history or bookmarks. */ -/** Default sync server URL used when not specified in shareable URLs. - * Can be overridden at build time via the VITE_DEFAULT_SYNC_SERVER environment variable. */ +/** * Prefix a path with the hub's mount base path, if any. */ +export function hubPath(path: string): string { + return `${import.meta.env.VITE_HUB_BASE_PATH ?? ''}${path}`; +} + +/** + * Default sync server URL used when not specified in shareable URLs. + * + * An explicit `VITE_DEFAULT_SYNC_SERVER` takes precedence, followed by a + * subpath-aware `/ws`, and finally the public automerge.org sync server. + */ export const DEFAULT_SYNC_SERVER = - import.meta.env.VITE_DEFAULT_SYNC_SERVER || 'wss://sync.automerge.org'; + import.meta.env.VITE_DEFAULT_SYNC_SERVER || + (import.meta.env.VITE_HUB_BASE_PATH ? hubPath('/ws') : 'wss://sync.automerge.org'); + +/** + * Resolve a sync server URL to an absolute WebSocket URL. + * + * When the hub is mounted under a subpath, `DEFAULT_SYNC_SERVER` is a relative + * path such as `/subpath/ws` (derived from `VITE_HUB_BASE_PATH`). + * This function expands relative paths (those starting with `/`) against the + * current page origin at runtime so they become valid WebSocket URLs. Absolute + * URLs (starting with `wss://`, `ws://`, etc.) are returned unchanged. + * + * @param syncServer - The sync server value from config or a shareable URL. + * @returns An absolute WebSocket URL ready to pass to the WS adapter. + */ +export function resolveSyncServerUrl(syncServer: string): string { + if (!syncServer.startsWith('/')) { + return syncServer; + } + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${proto}//${window.location.host}${syncServer}`; +} // ============================================================================ // Types diff --git a/hub-client/src/vite-env.d.ts b/hub-client/src/vite-env.d.ts index a76df6a1e..78e9f1b04 100644 --- a/hub-client/src/vite-env.d.ts +++ b/hub-client/src/vite-env.d.ts @@ -5,6 +5,8 @@ interface ImportMetaEnv { readonly VITE_DEFAULT_SYNC_SERVER?: string /** Google OAuth2 client ID. When set, enables authentication. */ readonly VITE_GOOGLE_CLIENT_ID?: string + /** Base path the hub is reverse-proxied at. */ + readonly VITE_HUB_BASE_PATH?: string } interface ImportMeta {