From 9f74d58db1159e5af02e8c59e3393b1ff0c63aea Mon Sep 17 00:00:00 2001 From: Levente Orban Date: Thu, 6 Nov 2025 22:31:16 +0100 Subject: [PATCH 1/7] feat: initialize federation service v1 --- Dockerfile | 2 +- README.md | 2 +- federation.config.js | 16 +++ src/lib/fetchFederatedEvents.ts | 146 ++++++++++++++++++++++++ src/lib/types.ts | 2 + src/routes/api/events/+server.ts | 55 +++++++++ src/routes/{ => api}/healthz/+server.ts | 0 src/routes/discover/+page.server.ts | 29 +++-- src/routes/discover/+page.svelte | 88 +++++++++----- 9 files changed, 300 insertions(+), 40 deletions(-) create mode 100644 federation.config.js create mode 100644 src/lib/fetchFederatedEvents.ts create mode 100644 src/routes/api/events/+server.ts rename src/routes/{ => api}/healthz/+server.ts (100%) diff --git a/Dockerfile b/Dockerfile index f65bc00..76d4f28 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ ENV PORT 3000 ENV HOSTNAME "0.0.0.0" HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/healthz || exit 1 + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/healthz || exit 1 EXPOSE 3000 CMD [ "node", "build" ] diff --git a/README.md b/README.md index 5f79a7a..51ddc5d 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Events that thrive anywhere. Like the cactus, great events bloom under any condition when managed with care. Cactoide(ae) helps you streamline RSVPs, simplify coordination, and keep every detail efficientβ€”so your gatherings are resilient, vibrant, and unforgettable.

- + actoide diff --git a/federation.config.js b/federation.config.js new file mode 100644 index 0000000..20faf77 --- /dev/null +++ b/federation.config.js @@ -0,0 +1,16 @@ +const config = { + name: 'Cactoide Genesis', + instances: [ + { + url: 'cactoide.org' + }, + { + url: 'cactoide.dalev.hu' + }, + { + url: 'localhost:5174' + } + ] +}; + +export default config; diff --git a/src/lib/fetchFederatedEvents.ts b/src/lib/fetchFederatedEvents.ts new file mode 100644 index 0000000..bd2c721 --- /dev/null +++ b/src/lib/fetchFederatedEvents.ts @@ -0,0 +1,146 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { logger } from '$lib/logger'; +import type { Event } from '$lib/types'; + +import config from '../../federation.config.js'; + +console.log(config.instances); + +interface FederationConfig { + name: string; + instances: Array<{ url: string }>; +} + +interface FederationEventsResponse { + events: Array; + count?: number; +} + +/** + * Reads the federation config file + */ +async function readFederationConfig(): Promise { + try { + const configPath = join(process.cwd(), 'federation.config.js'); + + // Use dynamic import to load the config file as a module + // This is safer than eval and works with ES modules + const configModule = await import(configPath + '?t=' + Date.now()); + const config = (configModule.default || configModule.config) as FederationConfig; + + if (config && config.instances && Array.isArray(config.instances)) { + return config; + } + + logger.warn('Invalid federation config structure'); + return null; + } catch (error) { + // If dynamic import fails, try reading as text and parsing + try { + const configPath = join(process.cwd(), 'federation.config.js'); + const configContent = readFileSync(configPath, 'utf-8'); + + // Try to extract JSON-like structure + const configMatch = configContent.match(/instances:\s*\[([\s\S]*?)\]/); + if (configMatch) { + // Simple parsing - extract URLs + const urlMatches = configContent.matchAll(/url:\s*['"]([^'"]+)['"]/g); + const instances = Array.from(urlMatches, (match) => ({ url: match[1] })); + + if (instances.length > 0) { + return { + name: 'Federated Instances', + instances + }; + } + } + } catch (fallbackError) { + logger.error({ error: fallbackError }, 'Error parsing federation.config.js as fallback'); + } + + logger.error({ error }, 'Error reading federation.config.js'); + return null; + } +} + +/** + * Fetches events from a single federated instance + */ +async function fetchEventsFromInstance(instanceUrl: string): Promise { + try { + // Ensure URL has protocol and append /api/events + + const apiUrl = `http://${instanceUrl}/api/events`; + + logger.debug({ apiUrl }, 'Fetching events from federated instance'); + + const response = await fetch(apiUrl, { + method: 'GET', + headers: { + Accept: 'application/json' + }, + signal: AbortSignal.timeout(10000) // 10 second timeout + }); + + if (!response.ok) { + logger.warn({ apiUrl, status: response.status }, 'Failed to fetch events from instance'); + return []; + } + + const data = (await response.json()) as FederationEventsResponse; + + if (!data.events || !Array.isArray(data.events)) { + logger.warn({ apiUrl }, 'Invalid events response structure'); + return []; + } + + // Mark events as federated and add source URL + const federatedEvents: Event[] = data.events.map((event) => ({ + ...event, + federation: true, + federation_url: `http://${instanceUrl}` + })); + + logger.info( + { apiUrl, eventCount: federatedEvents.length }, + 'Successfully fetched federated events' + ); + return federatedEvents; + } catch (error) { + logger.error( + { instanceUrl, error: error instanceof Error ? error.message : 'Unknown error' }, + 'Error fetching events from instance' + ); + return []; + } +} + +/** + * Fetches events from all configured federated instances + */ +export async function fetchAllFederatedEvents(): Promise { + const config = await readFederationConfig(); + + if (!config || !config.instances || config.instances.length === 0) { + logger.debug('No federation config or instances found'); + return []; + } + + logger.info( + { instanceCount: config.instances.length }, + 'Fetching events from federated instances' + ); + + // Fetch from all instances in parallel + const fetchPromises = config.instances.map((instance) => fetchEventsFromInstance(instance.url)); + + const results = await Promise.all(fetchPromises); + + // Flatten all events into a single array + const allFederatedEvents = results.flat(); + + logger.info({ totalEvents: allFederatedEvents.length }, 'Completed fetching federated events'); + + return allFederatedEvents; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index ae1fe33..4dab522 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -17,6 +17,8 @@ export interface Event { user_id: string; created_at: string; updated_at: string; + federation?: boolean; // Optional: true if event is from a federated instance + federation_url?: string; // Optional: URL of the federated instance this event came from } export interface RSVP { diff --git a/src/routes/api/events/+server.ts b/src/routes/api/events/+server.ts new file mode 100644 index 0000000..21296a9 --- /dev/null +++ b/src/routes/api/events/+server.ts @@ -0,0 +1,55 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { database } from '$lib/database/db'; +import { events } from '$lib/database/schema'; +import { desc, eq } from 'drizzle-orm'; +import { logger } from '$lib/logger'; + +import { FEDERATION_INSTANCE } from '$env/static/private'; + +export const GET: RequestHandler = async () => { + try { + if (!FEDERATION_INSTANCE) { + return json({ error: 'Federation API is not enabled on this instance' }, { status: 403 }); + } + + // Fetch all public and invite-only events ordered by creation date (newest first) + const publicEvents = await database + .select() + .from(events) + .where(eq(events.visibility, 'public')) + .orderBy(desc(events.createdAt)); + + // Transform events to include federation_event type + const transformedEvents = publicEvents.map((event) => ({ + id: event.id, + name: event.name, + date: event.date, + time: event.time, + location: event.location, + location_type: event.locationType, + location_url: event.locationUrl, + type: event.type, + federation: true, + attendee_limit: event.attendeeLimit, + visibility: event.visibility, + user_id: event.userId, + created_at: event.createdAt?.toISOString() || '', + updated_at: event.updatedAt?.toISOString() || '' + })); + + return json({ + events: transformedEvents, + count: transformedEvents.length + }); + } catch (error) { + logger.error({ error }, 'Error fetching events from API'); + return json( + { + error: 'Failed to fetch events', + message: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ); + } +}; diff --git a/src/routes/healthz/+server.ts b/src/routes/api/healthz/+server.ts similarity index 100% rename from src/routes/healthz/+server.ts rename to src/routes/api/healthz/+server.ts diff --git a/src/routes/discover/+page.server.ts b/src/routes/discover/+page.server.ts index 4d78b2d..08fd02e 100644 --- a/src/routes/discover/+page.server.ts +++ b/src/routes/discover/+page.server.ts @@ -3,10 +3,11 @@ import { desc, inArray } from 'drizzle-orm'; import type { PageServerLoad } from './$types'; import { events } from '$lib/database/schema'; import { logger } from '$lib/logger'; +import { fetchAllFederatedEvents } from '$lib/fetchFederatedEvents'; export const load: PageServerLoad = async () => { try { - // Fetch all non-private events (public and invite-only) ordered by creation date (newest first) + // Fetch all non-private events ordered by creation date (newest first) const publicEvents = await database .select() .from(events) @@ -17,24 +18,36 @@ export const load: PageServerLoad = async () => { const transformedEvents = publicEvents.map((event) => ({ id: event.id, name: event.name, - date: event.date, // Already in 'YYYY-MM-DD' format - time: event.time, // Already in 'HH:MM:SS' format + date: event.date, + time: event.time, location: event.location, location_type: event.locationType, location_url: event.locationUrl, type: event.type, - attendee_limit: event.attendeeLimit, // Note: schema uses camelCase + attendee_limit: event.attendeeLimit, visibility: event.visibility, - user_id: event.userId, // Note: schema uses camelCase + user_id: event.userId, created_at: event.createdAt?.toISOString(), - updated_at: event.updatedAt?.toISOString() + updated_at: event.updatedAt?.toISOString(), + federation: false // Add false for local events })); + // Fetch federated events from federation.config.js + let federatedEvents: typeof transformedEvents = []; + try { + federatedEvents = await fetchAllFederatedEvents(); + } catch (error) { + logger.error({ error }, 'Error fetching federated events, continuing with local events only'); + } + + // Merge local and federated events + const allEvents = [...transformedEvents, ...federatedEvents]; + return { - events: transformedEvents + events: allEvents }; } catch (error) { - logger.error({ error }, 'Error loading public events'); + logger.error({ error }, 'Error loading events'); // Return empty array on error to prevent page crash return { diff --git a/src/routes/discover/+page.svelte b/src/routes/discover/+page.svelte index ed18c45..0a46958 100644 --- a/src/routes/discover/+page.svelte +++ b/src/routes/discover/+page.svelte @@ -267,9 +267,15 @@

{#each filteredEvents as event, i (i)} -
-
-

{event.name}

+ {@const isFederated = event.federation === true} +
+
+
+

{event.name}

+
@@ -314,36 +320,58 @@ {event.location} {/if}
-
- - {event.type === 'limited' ? t('common.limited') : t('common.unlimited')} - -
-
- - {event.visibility === 'public' ? t('common.public') : t('common.inviteOnly')} - -
+ {#if isFederated && event.federation_url} +
+ + {event.federation_url} + +
{:else} +
+ + {event.type === 'limited' ? t('common.limited') : t('common.unlimited')} + +
+
+ + {event.visibility === 'public' + ? t('common.public') + : t('common.inviteOnly')} + +
{/if}
-
- +
+ {#if isFederated && event.federation_url} + + View + + {:else} + + {/if}
{/each} From c3f420df74358f390bdf79739b1994ac2cffb201 Mon Sep 17 00:00:00 2001 From: Levente Orban Date: Thu, 6 Nov 2025 22:57:27 +0100 Subject: [PATCH 2/7] feat: federation instance list and api/healthz improvements --- federation.config.js | 3 + src/lib/components/Navbar.svelte | 7 ++ src/lib/i18n/it.json | 3 +- src/lib/i18n/messages.json | 3 +- src/routes/api/federation/info/+server.ts | 34 ++++++ src/routes/api/healthz/+server.ts | 18 ++- src/routes/instance/+page.server.ts | 119 +++++++++++++++++++ src/routes/instance/+page.svelte | 136 ++++++++++++++++++++++ 8 files changed, 318 insertions(+), 5 deletions(-) create mode 100644 src/routes/api/federation/info/+server.ts create mode 100644 src/routes/instance/+page.server.ts create mode 100644 src/routes/instance/+page.svelte diff --git a/federation.config.js b/federation.config.js index 20faf77..05e6467 100644 --- a/federation.config.js +++ b/federation.config.js @@ -9,6 +9,9 @@ const config = { }, { url: 'localhost:5174' + }, + { + url: 'localhost:5175' } ] }; diff --git a/src/lib/components/Navbar.svelte b/src/lib/components/Navbar.svelte index e056062..290814d 100644 --- a/src/lib/components/Navbar.svelte +++ b/src/lib/components/Navbar.svelte @@ -48,6 +48,13 @@ {t('navigation.create')} + +
From 7d75020cc1a7965aeb75c9947cc0aa41faec0aa7 Mon Sep 17 00:00:00 2001 From: Levente Orban Date: Fri, 7 Nov 2025 14:10:42 +0100 Subject: [PATCH 7/7] fix: readme --- README.md | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 0d92a41..a36d6d5 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Like the cactus, great events bloom under any condition when managed with care. #### What is it? -A mobile-first event RSVP platform that lets you create events, share unique URLs, and collect RSVPs without any registration required. With built-in federation, discover and share events across a decentralized network of instances. +A federated mobile-first event RSVP platform that lets you create events, share unique URLs, and collect RSVPs without any registration required. With built-in federation, discover and share events across a decentralized network of instances. ### ✨ Features @@ -71,21 +71,21 @@ Cactoide supports federation, allowing multiple instances to share and discover Federation Example

-#### How Federation Works +#### How it works Federation is managed through the `federation.config.js` file, which contains: -- **Instance name**: The display name for your instance -- **Instance list**: An array of federated instance URLs +- **Instance name**: The display name for your instance when exposing events to the federation +- **Instance list**: An array of federated instance URLs. Add instance URLs here to discover events from other federated instances. ```javascript const config = { name: 'Cactoide Genesis', - instances: [{ url: 'cactoide.org' }, { url: 'cactoide.dalev.hu' }] + instances: [{ url: 'js-meetups.seattle.io' }, { url: 'ai-events.seattle.com' }] }; ``` -#### Opting In to Federation +#### Opt-in To enable federation on your instance, you need to: @@ -93,24 +93,22 @@ To enable federation on your instance, you need to: 2. **Configure your instance name**: Update the `name` field in your `federation.config.js` file to set your instance's display name. -3. **Expose required endpoints**: Your instance will automatically expose: - - `/api/federation/events` - Returns all public events from your instance - - `/api/federation/info` - Returns your instance name and public events count +Your instance will automatically expose: -#### Adding Your Instance to the Global Federation +- `/api/federation/events` - Returns all public events from your instance +- `/api/federation/info` - Returns your instance name and public events count + +#### Adding your instance To add your instance to the global federation list (so other instances can discover your events): 1. Fork the [Cactoide repository](https://github.com/polaroi8d/cactoide) -2. Add your instance URL to the `instances` array in `federation.config.js`: - ```javascript - instances: [{ url: 'your-instance.com' }]; - ``` +2. Add your instance URL to the `instances` array in [`federation.config.js`](https://github.com/polaroi8d/cactoide/blob/main/federation.config.js): 3. Open a pull request to the main repository Once merged, your instance will appear in the federation network, and other instances will be able to discover and display your public events. -You can view all registered federated instances in the main repository: `federation.config.js` file. +You can view all registered federated instances in the main repository: [`federation.config.js`](https://github.com/polaroi8d/cactoide/blob/main/federation.config.js) file. ### Options