Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
48 changes: 46 additions & 2 deletions src/app/(dashboard)/(modules)/router/event-relays/page.tsx
Original file line number Diff line number Diff line change
@@ -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<{
Expand All @@ -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,
Expand All @@ -20,12 +29,47 @@ export default async function EventRelaysPage(props: {
getRouterSettings(),
]);

if (
relaysResult.status === 'rejected' &&
isAxiosNotFoundError(relaysResult.reason)
) {
return (
<div className="p-6">
<PageHeader>
<div>
<PageTitle>Event Relays</PageTitle>
<PageDescription>
Forward exact bus events to ReBAC-scoped socket subscribers.
</PageDescription>
</div>
</PageHeader>
<div className="mt-6">
<EmptyState
icon={Radio}
title="Event Relays are not available"
description="This Router does not expose /router/event-relays yet. Upgrade to a build that includes the event relays Admin API (Conduit PR #1600), then reload this page."
/>
</div>
</div>
);
}

if (relaysResult.status === 'rejected') {
throw relaysResult.reason;
}

const { relays, count } = relaysResult.value;
const socketsEnabled =
settingsResult.status === 'fulfilled'
? settingsResult.value.config.transports.sockets
: undefined;

return (
<div className="p-6">
<EventRelayList
relays={relays}
count={count}
socketsEnabled={config.transports.sockets}
socketsEnabled={socketsEnabled}
/>
</div>
);
Expand Down
75 changes: 56 additions & 19 deletions src/components/router/event-relays/event-relay-docs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -73,7 +66,8 @@ export function EventRelayDocs({ open, onOpenChange }: EventRelayDocsProps) {
</span>
<span className="mt-0.5 block text-sm text-pretty text-muted-foreground">
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.
</span>
</span>
<ChevronDown
Expand Down Expand Up @@ -127,10 +121,13 @@ export function EventRelayDocs({ open, onOpenChange }: EventRelayDocsProps) {

<section>
<h3 className="text-sm font-medium text-foreground">
Configure a relay
Database realtime example
</h3>
<p className="mt-1 text-sm text-pretty text-muted-foreground">
Example: notify clients when an Order document changes.
Notify clients when an Order document changes via{' '}
<Code>database:change:Order</Code>. Database realtime payloads
expose <Code>documentId</Code> (not Mongo <Code>_id</Code> on
the wire).
</p>
<dl className="mt-3 divide-y divide-border/60 rounded-md border border-border/60">
<Field
Expand All @@ -150,28 +147,61 @@ export function EventRelayDocs({ open, onOpenChange }: EventRelayDocsProps) {
/>
<Field
name="resourceIdPath"
value="_id or documentId"
hint="Dot path into the bus JSON. Match the publisher’s payload."
value="documentId"
hint="Dot path into the bus JSON from Database realtime."
/>
<Field
name="messageTemplate"
value={'{ "id": "{{payload._id}}" }'}
value={'{ "id": "{{payload.documentId}}" }'}
hint="JSON with {{payload.path}} placeholders against the bus payload."
/>
</dl>
</section>

<section>
<h3 className="text-sm font-medium text-foreground">
CRUD bus channel (advanced)
</h3>
<p className="mt-1 text-sm text-pretty text-muted-foreground">
You can relay <Code>database:update:Order</Code> instead, but
the payload is the full document (including{' '}
<Code>_id</Code>). That duplicates what clients already get on{' '}
<Code>/database/</Code> <Code>change</Code> — prefer the
database realtime channel unless you only consume{' '}
<Code>/events/</Code>.
</p>
<dl className="mt-3 divide-y divide-border/60 rounded-md border border-border/60">
<Field
name="busEvent"
value="database:update:Order"
hint="Exact CRUD bus channel; large payloads."
/>
<Field
name="resourceIdPath"
value="_id"
hint="Mongo id on the full document payload."
/>
<Field
name="messageTemplate"
value={'{ "id": "{{payload._id}}" }'}
hint="Same template language; mind payload size and duplication."
/>
</dl>
</section>

<section>
<h3 className="text-sm font-medium text-foreground">
Subscribe from a client
</h3>
<p className="mt-1 text-sm text-pretty text-muted-foreground">
Connect to <Code>{'/events/'}</Code> with{' '}
<Code>{'path: /realtime'}</Code> and a user bearer token. Then
subscribe with the relay id and resource id.
<Code>{'path: /realtime'}</Code> and{' '}
<Code>{'auth: { token: accessToken }'}</Code>. Re-subscribe
inside <Code>connect</Code> so reconnects re-join the room.
There is no replay — missed events are lost.
</p>
<pre className="mt-3 overflow-x-auto rounded-md bg-muted p-3 font-mono text-[11px] leading-5 text-foreground slashed-zero">
{CLIENT_SNIPPET}
{EVENT_RELAY_DOCS_SNIPPET}
</pre>
</section>

Expand All @@ -182,13 +212,20 @@ export function EventRelayDocs({ open, onOpenChange }: EventRelayDocsProps) {
Bus channels must match exactly. Patterns like{' '}
<Code>{'database:change:*'}</Code> are not supported.
</li>
<li>
Subscribe-only: clients do not publish on{' '}
<Code>/events/</Code>. Modules write to the bus.
</li>
<li>
Subscribe fails closed if Authorization is unavailable or the
user lacks permission.
</li>
<li>
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.
</li>
<li>
Turn a relay off with Active to stop forwarding and evict
subscribers without deleting the relay.
</li>
</ul>
</section>
Expand Down
Loading
Loading