diff --git a/package.json b/package.json
index 62ec88456..4025177d9 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
"start": "next start -p 8080",
"lint": "eslint .",
"test:semantic-colors": "node --experimental-strip-types --test src/lib/semantic-colors.test.ts src/lib/reactflow-edge-colors.test.ts",
- "test:event-relays": "node --experimental-strip-types --test src/lib/event-relays/preview.test.ts",
+ "test:event-relays": "node --experimental-strip-types --test src/lib/event-relays/path.test.ts src/lib/event-relays/client-snippet.test.ts src/lib/event-relays/preview-remote.test.ts src/lib/event-relays/sample-payload.test.ts src/lib/logic/api-error.test.ts",
"test:schema-fields": "node --experimental-strip-types --test src/lib/database/schema-field-definition.test.ts src/lib/database/system-schema-fields.test.ts",
"build:docker": "docker build --platform linux/amd64 -t ghcr.io/conduitplatform/conduit-ui:latest .",
"prepare": "husky",
diff --git a/src/app/(dashboard)/(modules)/router/event-relays/page.tsx b/src/app/(dashboard)/(modules)/router/event-relays/page.tsx
index 509f7094a..132958497 100644
--- a/src/app/(dashboard)/(modules)/router/event-relays/page.tsx
+++ b/src/app/(dashboard)/(modules)/router/event-relays/page.tsx
@@ -1,5 +1,13 @@
import { getEventRelays, getRouterSettings } from '@/lib/api/router';
import { EventRelayList } from '@/components/router/event-relays/event-relay-list';
+import {
+ PageDescription,
+ PageHeader,
+ PageTitle,
+} from '@/components/ui/page-header';
+import { EmptyState } from '@/components/ui/empty-state';
+import { Radio } from 'lucide-react';
+import { isAxiosNotFoundError } from '@/lib/logic/api-error';
export default async function EventRelaysPage(props: {
searchParams: Promise<{
@@ -11,7 +19,8 @@ export default async function EventRelaysPage(props: {
const searchParams = await props.searchParams;
const skip = Number(searchParams.skip ?? 0);
const limit = Number(searchParams.limit ?? 10);
- const [{ relays, count }, { config }] = await Promise.all([
+
+ const [relaysResult, settingsResult] = await Promise.allSettled([
getEventRelays({
skip,
limit,
@@ -20,12 +29,47 @@ export default async function EventRelaysPage(props: {
getRouterSettings(),
]);
+ if (
+ relaysResult.status === 'rejected' &&
+ isAxiosNotFoundError(relaysResult.reason)
+ ) {
+ return (
+
+
+
+
Event Relays
+
+ Forward exact bus events to ReBAC-scoped socket subscribers.
+
+
+
+
+
+
+
+ );
+ }
+
+ if (relaysResult.status === 'rejected') {
+ throw relaysResult.reason;
+ }
+
+ const { relays, count } = relaysResult.value;
+ const socketsEnabled =
+ settingsResult.status === 'fulfilled'
+ ? settingsResult.value.config.transports.sockets
+ : undefined;
+
return (
);
diff --git a/src/components/router/event-relays/event-relay-docs.tsx b/src/components/router/event-relays/event-relay-docs.tsx
index a1c3ed895..99623a699 100644
--- a/src/components/router/event-relays/event-relay-docs.tsx
+++ b/src/components/router/event-relays/event-relay-docs.tsx
@@ -8,14 +8,7 @@ import {
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
-
-const CLIENT_SNIPPET = `const socket = io(\`\${SOCKET_URL}/events/\`, {
- path: '/realtime',
- extraHeaders: { authorization: \`Bearer \${accessToken}\` },
-});
-socket.emit('subscribe', relayId, resourceId);
-socket.on('order-updated', payload => {});
-socket.emit('unsubscribe', relayId, resourceId);`;
+import { EVENT_RELAY_DOCS_SNIPPET } from '@/lib/event-relays/client-snippet';
const STEPS = [
{
@@ -73,7 +66,8 @@ export function EventRelayDocs({ open, onOpenChange }: EventRelayDocsProps) {
Forward an exact bus event to permission-scoped socket
- subscribers. Not a queue, and not a generic websocket broadcast.
+ subscribers. Subscribe-only, ephemeral, and not a generic
+ websocket broadcast.
- Configure a relay
+ Database realtime example
- Example: notify clients when an Order document changes.
+ Notify clients when an Order document changes via{' '}
+ database:change:Order. Database realtime payloads
+ expose documentId (not Mongo _id on
+ the wire).
+
+
+ CRUD bus channel (advanced)
+
+
+ You can relay database:update:Order instead, but
+ the payload is the full document (including{' '}
+ _id). That duplicates what clients already get on{' '}
+ /database/ change — prefer the
+ database realtime channel unless you only consume{' '}
+ /events/.
+
+
+
+
+
+
+
+
Subscribe from a client
Connect to {'/events/'} with{' '}
- {'path: /realtime'} and a user bearer token. Then
- subscribe with the relay id and resource id.
+ {'path: /realtime'} and{' '}
+ {'auth: { token: accessToken }'}. Re-subscribe
+ inside connect so reconnects re-join the room.
+ There is no replay — missed events are lost.
- {CLIENT_SNIPPET}
+ {EVENT_RELAY_DOCS_SNIPPET}
@@ -182,13 +212,20 @@ export function EventRelayDocs({ open, onOpenChange }: EventRelayDocsProps) {
Bus channels must match exactly. Patterns like{' '}
{'database:change:*'} are not supported.
+
+ Subscribe-only: clients do not publish on{' '}
+ /events/. Modules write to the bus.
+
Subscribe fails closed if Authorization is unavailable or the
user lacks permission.
- Turn a relay off with Active to stop forwarding without
- deleting it. Deleting drops current subscribers immediately.
+ No replay or ordering guarantee. Delivery is ephemeral.
+
+
+ Turn a relay off with Active to stop forwarding and evict
+ subscribers without deleting the relay.
diff --git a/src/components/router/event-relays/event-relay-form.tsx b/src/components/router/event-relays/event-relay-form.tsx
index 92694b7e3..f2eb40d49 100644
--- a/src/components/router/event-relays/event-relay-form.tsx
+++ b/src/components/router/event-relays/event-relay-form.tsx
@@ -1,6 +1,5 @@
'use client';
-import { useMemo } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { rhfZodResolver } from '@/lib/zod-form';
import { Form } from '@/components/ui/form';
@@ -12,14 +11,17 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import {
EventRelayFormSchema,
EventRelayFormValues,
- parseJsonField,
+ parseMessageTemplateField,
} from '@/components/router/event-relays/zod';
-import { previewEventRelay } from '@/lib/event-relays/preview';
+import { buildEventRelayClientSnippet } from '@/lib/event-relays/client-snippet';
+import { useEventRelayPreview } from '@/components/router/event-relays/use-event-relay-preview';
import { EventRelay, EventRelayWriteRequest } from '@/lib/models/Router';
+import {
+ buildDefaultSamplePayload,
+ DEFAULT_CREATE_SAMPLE_PAYLOAD,
+} from '@/lib/event-relays/sample-payload';
-const DEFAULT_TEMPLATE = '{\n "id": "{{payload._id}}"\n}';
-const DEFAULT_SAMPLE =
- '{\n "_id": "64f1c0a2b4d0e1f2a3b4c5d6",\n "status": "paid"\n}';
+const DEFAULT_TEMPLATE = '{\n "id": "{{payload.documentId}}"\n}';
interface EventRelayFormProps {
relay?: EventRelay | null;
@@ -34,6 +36,8 @@ export function EventRelayForm({
onCancel,
isSaving,
}: EventRelayFormProps) {
+ const isEditing = Boolean(relay);
+
const form = useForm({
resolver: rhfZodResolver(EventRelayFormSchema),
defaultValues: {
@@ -43,63 +47,53 @@ export function EventRelayForm({
busEvent: relay?.busEvent ?? '',
socketEvent: relay?.socketEvent ?? '',
resourceType: relay?.resourceType ?? '',
- resourceIdPath: relay?.resourceIdPath ?? '_id',
+ resourceIdPath: relay?.resourceIdPath ?? 'documentId',
permission: relay?.permission ?? 'read',
messageTemplate: relay
? JSON.stringify(relay.messageTemplate, null, 2)
: DEFAULT_TEMPLATE,
- samplePayload: DEFAULT_SAMPLE,
+ samplePayload: relay
+ ? buildDefaultSamplePayload(relay.resourceIdPath)
+ : DEFAULT_CREATE_SAMPLE_PAYLOAD,
},
});
const watched = useWatch({ control: form.control });
- const preview = useMemo(() => {
- try {
- const template = parseJsonField(
- watched.messageTemplate ?? '',
- 'Message template'
- );
- const sample = parseJsonField(
- watched.samplePayload?.trim() || '{}',
- 'Sample payload'
- );
- return previewEventRelay({
- resourceIdPath: watched.resourceIdPath || '_id',
- messageTemplate: template,
- samplePayload: sample,
- });
- } catch (err) {
- return {
- error: err instanceof Error ? err.message : String(err),
- };
- }
- }, [watched.messageTemplate, watched.samplePayload, watched.resourceIdPath]);
+ const preview = useEventRelayPreview({
+ messageTemplate: watched.messageTemplate ?? '',
+ samplePayload: watched.samplePayload ?? '',
+ resourceIdPath: watched.resourceIdPath ?? 'documentId',
+ });
const handleSubmit = form.handleSubmit(async values => {
- const messageTemplate = parseJsonField(
- values.messageTemplate,
- 'Message template'
- );
+ const messageTemplate = parseMessageTemplateField(values.messageTemplate);
const notes = values.notes?.trim();
- await onSubmit({
+ const payload: EventRelayWriteRequest = {
name: values.name,
notes: notes || undefined,
- active: values.active,
busEvent: values.busEvent,
socketEvent: values.socketEvent,
resourceType: values.resourceType,
resourceIdPath: values.resourceIdPath,
permission: values.permission,
messageTemplate,
- });
+ };
+ if (!isEditing) {
+ payload.active = values.active;
+ }
+ await onSubmit(payload);
});
+ const clientSnippet = buildEventRelayClientSnippet(
+ watched.socketEvent ?? 'your-event'
+ );
+
return (