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 {