From 889dfe9b4aefdef6530b940d4620d34fde1a51e5 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:33:19 +0200 Subject: [PATCH] feat(server): add fetchSpaceInviteSnapshot helper for cross-instance preview fetch --- .../src/utils/spaceInviteSnapshot.test.ts | 56 +++++++++++++++++++ .../server/src/utils/spaceInviteSnapshot.ts | 48 ++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 packages/server/src/utils/spaceInviteSnapshot.test.ts create mode 100644 packages/server/src/utils/spaceInviteSnapshot.ts diff --git a/packages/server/src/utils/spaceInviteSnapshot.test.ts b/packages/server/src/utils/spaceInviteSnapshot.test.ts new file mode 100644 index 00000000..efe1e620 --- /dev/null +++ b/packages/server/src/utils/spaceInviteSnapshot.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { fetchSpaceInviteSnapshot } from './spaceInviteSnapshot'; + +describe('fetchSpaceInviteSnapshot', () => { + const originalFetch = global.fetch; + beforeEach(() => { global.fetch = vi.fn() as any; }); + afterEach(() => { global.fetch = originalFetch; }); + + it('returns snapshot when preview endpoint succeeds', async () => { + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + spaceId: 'S1', + spaceName: 'Aether', + description: 'desc', + icon: null, + avatarColor: 'mint', + memberCount: 12, + instanceName: 'Backspace', + }), + }); + const snap = await fetchSpaceInviteSnapshot('https://z.example', 'abc123'); + expect(snap).toEqual({ + spaceId: 'S1', + spaceName: 'Aether', + description: 'desc', + icon: null, + avatarColor: 'mint', + memberCount: 12, + instanceName: 'Backspace', + }); + }); + + it('returns null when preview returns 404', async () => { + (global.fetch as any).mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({}) }); + const snap = await fetchSpaceInviteSnapshot('https://z.example', 'badcode'); + expect(snap).toBeNull(); + }); + + it('returns null when fetch throws (network error)', async () => { + (global.fetch as any).mockRejectedValueOnce(new Error('ECONNREFUSED')); + const snap = await fetchSpaceInviteSnapshot('https://z.example', 'abc123'); + expect(snap).toBeNull(); + }); + + it('aborts after timeout', async () => { + (global.fetch as any).mockImplementationOnce((_url: string, opts: any) => { + return new Promise((_resolve, reject) => { + opts.signal?.addEventListener('abort', () => reject(new Error('aborted'))); + }); + }); + const snap = await fetchSpaceInviteSnapshot('https://z.example', 'abc', 50); + expect(snap).toBeNull(); + }); +}); diff --git a/packages/server/src/utils/spaceInviteSnapshot.ts b/packages/server/src/utils/spaceInviteSnapshot.ts new file mode 100644 index 00000000..99d3aa3a --- /dev/null +++ b/packages/server/src/utils/spaceInviteSnapshot.ts @@ -0,0 +1,48 @@ +import type { AvatarColor } from '@backspace/shared'; + +export interface SpaceInviteSnapshot { + spaceId: string; + spaceName: string; + description: string | null; + icon: string | null; + avatarColor: AvatarColor | null; + memberCount: number; + instanceName: string; +} + +/** + * Fetch a space invite preview from a (possibly remote) instance. + * Returns null on 4xx, network error, or timeout — caller should treat as + * "invite no longer valid". + * + * Uses a 5s default timeout so a slow/unreachable Z does not stall the + * caller-instance request. + */ +export async function fetchSpaceInviteSnapshot( + spaceInstanceOrigin: string, + inviteCode: string, + timeoutMs = 5000, +): Promise { + const url = `${spaceInstanceOrigin}/api/spaces/invite/${encodeURIComponent(inviteCode)}/preview`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { signal: controller.signal }); + if (!res.ok) return null; + const data = await res.json() as Partial; + if (typeof data?.spaceId !== 'string' || typeof data?.spaceName !== 'string') return null; + return { + spaceId: data.spaceId, + spaceName: data.spaceName, + description: data.description ?? null, + icon: data.icon ?? null, + avatarColor: data.avatarColor ?? null, + memberCount: typeof data.memberCount === 'number' ? data.memberCount : 0, + instanceName: data.instanceName ?? 'Backspace', + }; + } catch { + return null; + } finally { + clearTimeout(timer); + } +}