From f487e4426c43ccd6d6dbc3c71ab414a31bfd42de Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 12:16:41 +0000 Subject: [PATCH 1/4] fix(router): align event relays UI with relay contract Use auth.token client snippets with subscribe on connect, documentId defaults, server preview API, inline Active toggles, API 404 fallback, and docs for subscribe-only ReBAC delivery. Co-authored-by: Konstantinos Kopanidis --- package.json | 2 +- .../(modules)/router/event-relays/page.tsx | 48 +++++++- .../router/event-relays/event-relay-docs.tsx | 75 ++++++++---- .../router/event-relays/event-relay-form.tsx | 107 ++++++++++-------- .../router/event-relays/event-relay-list.tsx | 51 +++++++-- .../event-relays/use-event-relay-preview.ts | 88 ++++++++++++++ src/components/router/event-relays/zod.ts | 54 ++++++++- src/lib/api/router/index.ts | 22 ++++ src/lib/event-relays/client-snippet.ts | 16 +++ src/lib/event-relays/path.test.ts | 16 +++ src/lib/event-relays/preview.test.ts | 31 ----- src/lib/event-relays/preview.ts | 33 ------ src/lib/event-relays/template.ts | 59 ---------- src/lib/models/Router.ts | 10 ++ 14 files changed, 407 insertions(+), 205 deletions(-) create mode 100644 src/components/router/event-relays/use-event-relay-preview.ts create mode 100644 src/lib/event-relays/client-snippet.ts create mode 100644 src/lib/event-relays/path.test.ts delete mode 100644 src/lib/event-relays/preview.test.ts delete mode 100644 src/lib/event-relays/preview.ts delete mode 100644 src/lib/event-relays/template.ts diff --git a/package.json b/package.json index 62ec88456..9342c6a37 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", "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..2c5d5428a 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 + : false; + 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..467e972f1 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,15 @@ 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'; -const DEFAULT_TEMPLATE = '{\n "id": "{{payload._id}}"\n}'; +const DEFAULT_TEMPLATE = '{\n "id": "{{payload.documentId}}"\n}'; const DEFAULT_SAMPLE = - '{\n "_id": "64f1c0a2b4d0e1f2a3b4c5d6",\n "status": "paid"\n}'; + '{\n "documentId": "64f1c0a2b4d0e1f2a3b4c5d6",\n "status": "paid"\n}'; interface EventRelayFormProps { relay?: EventRelay | null; @@ -34,6 +34,8 @@ export function EventRelayForm({ onCancel, isSaving, }: EventRelayFormProps) { + const isEditing = Boolean(relay); + const form = useForm({ resolver: rhfZodResolver(EventRelayFormSchema), defaultValues: { @@ -43,7 +45,7 @@ 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) @@ -53,38 +55,19 @@ export function EventRelayForm({ }); 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({ name: values.name, notes: notes || undefined, - active: values.active, + active: isEditing ? relay?.active : values.active, busEvent: values.busEvent, socketEvent: values.socketEvent, resourceType: values.resourceType, @@ -94,12 +77,16 @@ export function EventRelayForm({ }); }); + const clientSnippet = buildEventRelayClientSnippet( + watched.socketEvent ?? 'your-event' + ); + return (
    - + {isEditing ? null : }

    Preview

    - Local only. Nothing is published to the bus. + Rendered by the Router Admin API. Nothing is published to the bus.

    - {preview.error ? ( -

    {preview.error}

    - ) : ( + {preview.kind === 'unavailable' ? ( +

    + Preview is not available on this Router build yet. Upgrade to a + version that includes{' '} + POST /router/event-relays/preview{' '} + (see{' '} + + Conduit #1600 + + ). +

    + ) : preview.kind === 'loading' ? ( +

    Rendering…

    + ) : preview.kind === 'error' ? ( +

    {preview.message}

    + ) : preview.kind === 'ready' ? (

    Resource{' '} - {watched.resourceType || 'Type'}:{preview.resourceId} + {watched.resourceType || 'Type'}: + {preview.resourceId ?? '…'}

                     {JSON.stringify(preview.payload, null, 2)}
                   
    + ) : ( +

    + Enter valid JSON in the message template and sample payload to + preview. +

    )} Client contract

    - Connect to /events/ with{' '} - path: /realtime and a bearer - token. Then emit{' '} - subscribe(relayId, resourceId). + Subscribe-only: clients listen on /events/{' '} + with path: /realtime and{' '} + auth.token (browsers ignore{' '} + extraHeaders). The Authorization + module must be available — subscribe fails closed without a + matching ReBAC grant. Events are ephemeral with no replay.

    -              {`const socket = io(\`\${SOCKET_URL}/events/\`, {
    -  path: '/realtime',
    -  extraHeaders: { authorization: \`Bearer \${accessToken}\` },
    -});
    -socket.emit('subscribe', relayId, resourceId);
    -socket.on('${watched.socketEvent || 'your-event'}', payload => {});
    -socket.emit('unsubscribe', relayId, resourceId);`}
    +              {clientSnippet}
                 
    diff --git a/src/components/router/event-relays/event-relay-list.tsx b/src/components/router/event-relays/event-relay-list.tsx index c64e5a456..61d5f9ac8 100644 --- a/src/components/router/event-relays/event-relay-list.tsx +++ b/src/components/router/event-relays/event-relay-list.tsx @@ -4,7 +4,8 @@ import { useCallback, useMemo, useState } from 'react'; import { ColumnDef } from '@tanstack/react-table'; import { BookOpen, Plus, Radio, Zap } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; import { DataTable } from '@/components/ui/data-table'; import { EmptyState } from '@/components/ui/empty-state'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; @@ -55,6 +56,7 @@ export function EventRelayList({ const [isCreateOpen, setIsCreateOpen] = useState(false); const [editing, setEditing] = useState(null); const [docsOpen, setDocsOpen] = useState(count === 0); + const [togglingId, setTogglingId] = useState(null); const { save, isSaving } = useSettingsSave('Event Relay'); const toggleDocs = useCallback(() => { @@ -88,6 +90,23 @@ export function EventRelayList({ } }; + const handleActiveToggle = useCallback( + async (relay: EventRelay, active: boolean) => { + setTogglingId(relay._id); + await save({ + action: async () => { + await patchEventRelay(relay._id, { active }); + await refresh(); + }, + successMessage: active + ? 'Event relay enabled' + : 'Event relay disabled', + }); + setTogglingId(null); + }, + [refresh, save] + ); + const handleUpdate = async (data: EventRelayWriteRequest) => { if (!editing) return; const result = await save({ @@ -147,12 +166,28 @@ export function EventRelayList({ }, { accessorKey: 'active', - header: 'Status', - cell: ({ row }) => ( - - {row.original.active ? 'Active' : 'Disabled'} - - ), + header: 'Active', + cell: ({ row }) => { + const relay = row.original; + const busy = togglingId === relay._id || isSaving; + return ( +
    + handleActiveToggle(relay, checked)} + aria-label={`${relay.active ? 'Disable' : 'Enable'} ${relay.name}`} + /> + +
    + ); + }, }, { id: 'actions', @@ -183,7 +218,7 @@ export function EventRelayList({ ), }, ], - [save, refresh] + [handleActiveToggle, isSaving, save, refresh, togglingId] ); return ( diff --git a/src/components/router/event-relays/use-event-relay-preview.ts b/src/components/router/event-relays/use-event-relay-preview.ts new file mode 100644 index 000000000..28a50d321 --- /dev/null +++ b/src/components/router/event-relays/use-event-relay-preview.ts @@ -0,0 +1,88 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useDebounce } from '@uidotdev/usehooks'; +import { previewEventRelayRemote } from '@/lib/api/router'; +import { lookupOwnPath } from '@/lib/event-relays/path'; + +export type EventRelayPreviewState = + | { kind: 'idle' } + | { kind: 'loading' } + | { kind: 'unavailable' } + | { kind: 'error'; message: string } + | { kind: 'ready'; payload: unknown; resourceId?: string }; + +function tryParseJson(value: string): unknown | null { + try { + return JSON.parse(value); + } catch { + return null; + } +} + +export function useEventRelayPreview(options: { + messageTemplate: string; + samplePayload: string; + resourceIdPath: string; +}): EventRelayPreviewState { + const debounced = useDebounce(options, 400); + const [state, setState] = useState({ kind: 'idle' }); + + useEffect(() => { + const template = tryParseJson(debounced.messageTemplate); + if (template === null) { + setState({ kind: 'idle' }); + return; + } + + const sample = tryParseJson(debounced.samplePayload?.trim() || '{}'); + if (sample === null) { + setState({ kind: 'idle' }); + return; + } + + let cancelled = false; + void (async () => { + setState({ kind: 'loading' }); + const result = await previewEventRelayRemote({ + template, + sample, + }); + if (cancelled) return; + + if (result.status === 'unavailable') { + setState({ kind: 'unavailable' }); + return; + } + if (result.status === 'error') { + setState({ kind: 'error', message: result.message }); + return; + } + + let resourceId: string | undefined; + try { + const resolved = lookupOwnPath( + sample, + debounced.resourceIdPath.trim() || 'documentId' + ); + if (resolved !== undefined) { + resourceId = String(resolved); + } + } catch { + resourceId = undefined; + } + + setState({ + kind: 'ready', + payload: result.payload, + resourceId, + }); + })(); + + return () => { + cancelled = true; + }; + }, [debounced]); + + return state; +} diff --git a/src/components/router/event-relays/zod.ts b/src/components/router/event-relays/zod.ts index bbffddbc4..8d4b34ae6 100644 --- a/src/components/router/event-relays/zod.ts +++ b/src/components/router/event-relays/zod.ts @@ -1,6 +1,44 @@ import { z } from 'zod'; import { parseDotPath, RESERVED_SOCKET_EVENTS } from '@/lib/event-relays/path'; +function jsonObjectString(label: string) { + return z + .string() + .trim() + .min(1, `${label} is required`) + .superRefine((value, ctx) => { + try { + const parsed = JSON.parse(value); + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + ctx.addIssue({ + code: 'custom', + message: `${label} must be a JSON object`, + }); + } + } catch { + ctx.addIssue({ + code: 'custom', + message: `${label} must be valid JSON`, + }); + } + }); +} + +const optionalJsonObjectString = z + .string() + .optional() + .superRefine((value, ctx) => { + if (!value?.trim()) return; + try { + JSON.parse(value); + } catch { + ctx.addIssue({ + code: 'custom', + message: 'Sample payload must be valid JSON', + }); + } + }); + export const EventRelayFormSchema = z.object({ name: z .string() @@ -57,8 +95,8 @@ export const EventRelayFormSchema = z.object({ .trim() .min(1, 'Permission is required') .regex(/^[A-Za-z][A-Za-z0-9_]{0,63}$/, 'Permission is invalid'), - messageTemplate: z.string().trim().min(1, 'Message template is required'), - samplePayload: z.string().optional(), + messageTemplate: jsonObjectString('Message template'), + samplePayload: optionalJsonObjectString, }); export type EventRelayFormValues = z.infer; @@ -70,3 +108,15 @@ export function parseJsonField(value: string, label: string): unknown { throw new Error(`${label} must be valid JSON`); } } + +export function parseMessageTemplateField(value: string): Record { + const parsed = parseJsonField(value, 'Message template'); + if ( + parsed === null || + typeof parsed !== 'object' || + Array.isArray(parsed) + ) { + throw new Error('Message template must be a JSON object'); + } + return parsed as Record; +} diff --git a/src/lib/api/router/index.ts b/src/lib/api/router/index.ts index 5077e9b75..c702e30e5 100644 --- a/src/lib/api/router/index.ts +++ b/src/lib/api/router/index.ts @@ -2,10 +2,16 @@ import { getApiClient } from '@/lib/api'; import { EventRelay, + EventRelayPreviewRemoteResult, + EventRelayPreviewRequest, EventRelaysResponse, EventRelayWriteRequest, RouterSettings, } from '@/lib/models/Router'; +import { + formatAdminApiError, + isAxiosNotFoundError, +} from '@/lib/logic/api-error'; import { afterPatchServing } from '@/lib/api/modules/afterPatchServing'; import { PatchSettingsOptions } from '@/lib/api/modules/patch-settings-options'; @@ -131,3 +137,19 @@ export const patchEventRelay = async ( export const deleteEventRelay = async (id: string) => { await (await getApiClient()).delete(`/router/event-relays/${id}`); }; + +export const previewEventRelayRemote = async ( + body: EventRelayPreviewRequest +): Promise => { + try { + const res = await ( + await getApiClient() + ).post('/router/event-relays/preview', body); + return { status: 'ok', payload: res.data }; + } catch (err) { + if (isAxiosNotFoundError(err)) { + return { status: 'unavailable' }; + } + return { status: 'error', message: formatAdminApiError(err) }; + } +}; diff --git a/src/lib/event-relays/client-snippet.ts b/src/lib/event-relays/client-snippet.ts new file mode 100644 index 000000000..228132f94 --- /dev/null +++ b/src/lib/event-relays/client-snippet.ts @@ -0,0 +1,16 @@ +export function buildEventRelayClientSnippet(socketEvent: string): string { + const eventHandler = socketEvent.trim() || 'your-event'; + return `const socket = io(\`\${SOCKET_URL}/events/\`, { + path: '/realtime', + auth: { token: accessToken }, +}); +socket.on('connect', () => { + socket.emit('subscribe', relayId, resourceId); +}); +socket.on('${eventHandler}', payload => {}); +socket.emit('unsubscribe', relayId, resourceId);`; +} + +export const EVENT_RELAY_DOCS_SNIPPET = buildEventRelayClientSnippet( + 'order-updated' +); diff --git a/src/lib/event-relays/path.test.ts b/src/lib/event-relays/path.test.ts new file mode 100644 index 000000000..7bd0936bb --- /dev/null +++ b/src/lib/event-relays/path.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { lookupOwnPath, parseDotPath } from './path.ts'; + +describe('event relay path helpers', () => { + it('resolves dot paths on own properties only', () => { + assert.deepEqual(parseDotPath('documentId'), ['documentId']); + assert.equal(lookupOwnPath({ documentId: 'abc' }, 'documentId'), 'abc'); + assert.equal(lookupOwnPath({ a: 1 }, 'b'), undefined); + }); + + it('rejects prototype paths', () => { + assert.throws(() => parseDotPath('__proto__')); + assert.throws(() => parseDotPath('constructor')); + }); +}); diff --git a/src/lib/event-relays/preview.test.ts b/src/lib/event-relays/preview.test.ts deleted file mode 100644 index 50ecbd9df..000000000 --- a/src/lib/event-relays/preview.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { lookupOwnPath } from './path.ts'; -import { renderMessageTemplate } from './template.ts'; -import { previewEventRelay } from './preview.ts'; - -describe('event relay preview helpers', () => { - it('resolves the resource id and rendered payload', () => { - const result = previewEventRelay({ - resourceIdPath: '_id', - messageTemplate: { id: '{{payload._id}}', status: '{{payload.status}}' }, - samplePayload: { _id: 'order-1', status: 'paid' }, - }); - assert.equal(result.error, undefined); - assert.equal(result.resourceId, 'order-1'); - assert.deepEqual(result.payload, { id: 'order-1', status: 'paid' }); - }); - - it('fails closed on missing fields and prototype paths', () => { - assert.equal(lookupOwnPath({ a: 1 }, 'b'), undefined); - assert.throws(() => - renderMessageTemplate({ x: '{{payload.missing}}' }, {}) - ); - const preview = previewEventRelay({ - resourceIdPath: '__proto__', - messageTemplate: { id: '{{payload._id}}' }, - samplePayload: { _id: '1' }, - }); - assert.equal(typeof preview.error, 'string'); - }); -}); diff --git a/src/lib/event-relays/preview.ts b/src/lib/event-relays/preview.ts deleted file mode 100644 index c085cbced..000000000 --- a/src/lib/event-relays/preview.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { lookupOwnPath } from './path.ts'; -import { renderMessageTemplate } from './template.ts'; - -export type RelayPreview = { - resourceId?: string; - payload?: unknown; - error?: string; -}; - -export function previewEventRelay(options: { - resourceIdPath: string; - messageTemplate: unknown; - samplePayload: unknown; -}): RelayPreview { - try { - const resourceId = lookupOwnPath( - options.samplePayload, - options.resourceIdPath - ); - if (resourceId === undefined) { - return { - error: `Resource ID path '${options.resourceIdPath}' was not found`, - }; - } - const payload = renderMessageTemplate( - options.messageTemplate, - options.samplePayload - ); - return { resourceId: String(resourceId), payload }; - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } -} diff --git a/src/lib/event-relays/template.ts b/src/lib/event-relays/template.ts deleted file mode 100644 index 06b0808c7..000000000 --- a/src/lib/event-relays/template.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { lookupOwnPath, MAX_TEMPLATE_BYTES } from './path.ts'; - -const PLACEHOLDER = /\{\{\s*payload\.([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}/g; -const EXACT_PLACEHOLDER = /^\{\{\s*payload\.([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}$/; -const MAX_DEPTH = 10; - -export function renderMessageTemplate( - template: unknown, - payload: unknown -): unknown { - const serialized = JSON.stringify(template); - if (!serialized || serialized.length > MAX_TEMPLATE_BYTES) { - throw new Error('Message template is invalid or too large'); - } - return renderValue(template, payload, 0); -} - -function renderValue(value: unknown, payload: unknown, depth: number): unknown { - if (depth > MAX_DEPTH) { - throw new Error('Message template is nested too deeply'); - } - if (typeof value === 'string') { - return interpolateString(value, payload); - } - if (Array.isArray(value)) { - return value.map(item => renderValue(item, payload, depth + 1)); - } - if (value !== null && typeof value === 'object') { - const record = value as Record; - const output: Record = {}; - for (const key of Object.keys(record)) { - output[key] = renderValue(record[key], payload, depth + 1); - } - return output; - } - return value; -} - -function interpolateString(value: string, payload: unknown): unknown { - const exact = value.trim().match(EXACT_PLACEHOLDER); - if (exact) { - const resolved = lookupOwnPath(payload, exact[1]); - if (resolved === undefined) { - throw new Error(`Placeholder payload.${exact[1]} was not found`); - } - return resolved; - } - - return value.replace(PLACEHOLDER, (_match, path: string) => { - const resolved = lookupOwnPath(payload, path); - if (resolved === undefined) { - throw new Error(`Placeholder payload.${path} was not found`); - } - if (resolved === null || typeof resolved !== 'object') { - return String(resolved); - } - return JSON.stringify(resolved); - }); -} diff --git a/src/lib/models/Router.ts b/src/lib/models/Router.ts index 1aa55cfbb..cedf37689 100644 --- a/src/lib/models/Router.ts +++ b/src/lib/models/Router.ts @@ -118,3 +118,13 @@ export type EventRelayWriteRequest = { permission: string; messageTemplate: unknown; }; + +export type EventRelayPreviewRequest = { + template: unknown; + sample: unknown; +}; + +export type EventRelayPreviewRemoteResult = + | { status: 'ok'; payload: unknown } + | { status: 'error'; message: string } + | { status: 'unavailable' }; From 82d6d1d176b055c2fd1c4c3d369a30797eb622f1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 17:04:49 +0000 Subject: [PATCH 2/4] fix(router): match event relay preview API and follow-ups Align preview with Conduit #1604 (messageTemplate, samplePayload, { rendered }), debounce preview inputs by string, drop live-path unsubscribe from snippets, omit active on edit PATCH, and skip the sockets warning when Router settings fail to load. Co-authored-by: Konstantinos Kopanidis --- package.json | 2 +- .../(modules)/router/event-relays/page.tsx | 2 +- .../router/event-relays/event-relay-form.tsx | 13 ++++--- .../router/event-relays/event-relay-list.tsx | 6 ++-- .../event-relays/use-event-relay-preview.ts | 17 +++++---- src/lib/api/router/index.ts | 19 +++++++--- src/lib/event-relays/client-snippet.test.ts | 26 ++++++++++++++ src/lib/event-relays/client-snippet.ts | 3 +- src/lib/event-relays/preview-remote.test.ts | 33 +++++++++++++++++ src/lib/event-relays/preview-remote.ts | 35 +++++++++++++++++++ src/lib/models/Router.ts | 6 ++-- 11 files changed, 137 insertions(+), 25 deletions(-) create mode 100644 src/lib/event-relays/client-snippet.test.ts create mode 100644 src/lib/event-relays/preview-remote.test.ts create mode 100644 src/lib/event-relays/preview-remote.ts diff --git a/package.json b/package.json index 9342c6a37..8a35e2cc2 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/path.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", "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 2c5d5428a..132958497 100644 --- a/src/app/(dashboard)/(modules)/router/event-relays/page.tsx +++ b/src/app/(dashboard)/(modules)/router/event-relays/page.tsx @@ -62,7 +62,7 @@ export default async function EventRelaysPage(props: { const socketsEnabled = settingsResult.status === 'fulfilled' ? settingsResult.value.config.transports.sockets - : false; + : undefined; return (
    diff --git a/src/components/router/event-relays/event-relay-form.tsx b/src/components/router/event-relays/event-relay-form.tsx index 467e972f1..143b33c45 100644 --- a/src/components/router/event-relays/event-relay-form.tsx +++ b/src/components/router/event-relays/event-relay-form.tsx @@ -64,17 +64,20 @@ export function EventRelayForm({ const handleSubmit = form.handleSubmit(async values => { const messageTemplate = parseMessageTemplateField(values.messageTemplate); const notes = values.notes?.trim(); - await onSubmit({ + const payload: EventRelayWriteRequest = { name: values.name, notes: notes || undefined, - active: isEditing ? relay?.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( @@ -151,12 +154,12 @@ export function EventRelayForm({ POST /router/event-relays/preview{' '} (see{' '} - Conduit #1600 + Conduit #1604 ).

    diff --git a/src/components/router/event-relays/event-relay-list.tsx b/src/components/router/event-relays/event-relay-list.tsx index 61d5f9ac8..6e3d68577 100644 --- a/src/components/router/event-relays/event-relay-list.tsx +++ b/src/components/router/event-relays/event-relay-list.tsx @@ -44,7 +44,8 @@ import { useRouter } from 'next/navigation'; interface EventRelayListProps { relays: EventRelay[]; count: number; - socketsEnabled: boolean; + /** Undefined when Router settings could not be loaded. */ + socketsEnabled?: boolean; } export function EventRelayList({ @@ -197,6 +198,7 @@ export function EventRelayList({ type="button" variant="outline" size="sm" + disabled={togglingId === row.original._id} onClick={() => setEditing(row.original)} > Edit @@ -260,7 +262,7 @@ export function EventRelayList({ - {!socketsEnabled ? ( + {socketsEnabled === false ? ( WebSockets are disabled diff --git a/src/components/router/event-relays/use-event-relay-preview.ts b/src/components/router/event-relays/use-event-relay-preview.ts index 28a50d321..61cf35e50 100644 --- a/src/components/router/event-relays/use-event-relay-preview.ts +++ b/src/components/router/event-relays/use-event-relay-preview.ts @@ -25,17 +25,20 @@ export function useEventRelayPreview(options: { samplePayload: string; resourceIdPath: string; }): EventRelayPreviewState { - const debounced = useDebounce(options, 400); + const debouncedMessageTemplate = useDebounce(options.messageTemplate, 400); + const debouncedSamplePayload = useDebounce(options.samplePayload, 400); + const debouncedResourceIdPath = useDebounce(options.resourceIdPath, 400); + const [state, setState] = useState({ kind: 'idle' }); useEffect(() => { - const template = tryParseJson(debounced.messageTemplate); + const template = tryParseJson(debouncedMessageTemplate); if (template === null) { setState({ kind: 'idle' }); return; } - const sample = tryParseJson(debounced.samplePayload?.trim() || '{}'); + const sample = tryParseJson(debouncedSamplePayload?.trim() || '{}'); if (sample === null) { setState({ kind: 'idle' }); return; @@ -45,8 +48,8 @@ export function useEventRelayPreview(options: { void (async () => { setState({ kind: 'loading' }); const result = await previewEventRelayRemote({ - template, - sample, + messageTemplate: template, + samplePayload: sample, }); if (cancelled) return; @@ -63,7 +66,7 @@ export function useEventRelayPreview(options: { try { const resolved = lookupOwnPath( sample, - debounced.resourceIdPath.trim() || 'documentId' + debouncedResourceIdPath.trim() || 'documentId' ); if (resolved !== undefined) { resourceId = String(resolved); @@ -82,7 +85,7 @@ export function useEventRelayPreview(options: { return () => { cancelled = true; }; - }, [debounced]); + }, [debouncedMessageTemplate, debouncedSamplePayload, debouncedResourceIdPath]); return state; } diff --git a/src/lib/api/router/index.ts b/src/lib/api/router/index.ts index c702e30e5..241d74a9d 100644 --- a/src/lib/api/router/index.ts +++ b/src/lib/api/router/index.ts @@ -2,8 +2,8 @@ import { getApiClient } from '@/lib/api'; import { EventRelay, + EventRelayPreviewInput, EventRelayPreviewRemoteResult, - EventRelayPreviewRequest, EventRelaysResponse, EventRelayWriteRequest, RouterSettings, @@ -12,6 +12,10 @@ import { formatAdminApiError, isAxiosNotFoundError, } from '@/lib/logic/api-error'; +import { + buildEventRelayPreviewRequestBody, + parseEventRelayPreviewResponse, +} from '@/lib/event-relays/preview-remote'; import { afterPatchServing } from '@/lib/api/modules/afterPatchServing'; import { PatchSettingsOptions } from '@/lib/api/modules/patch-settings-options'; @@ -139,17 +143,24 @@ export const deleteEventRelay = async (id: string) => { }; export const previewEventRelayRemote = async ( - body: EventRelayPreviewRequest + body: EventRelayPreviewInput ): Promise => { try { const res = await ( await getApiClient() - ).post('/router/event-relays/preview', body); - return { status: 'ok', payload: res.data }; + ).post( + '/router/event-relays/preview', + buildEventRelayPreviewRequestBody(body) + ); + const payload = parseEventRelayPreviewResponse(res.data); + return { status: 'ok', payload }; } catch (err) { if (isAxiosNotFoundError(err)) { return { status: 'unavailable' }; } + if (err instanceof Error && err.message.startsWith('Preview response')) { + return { status: 'error', message: err.message }; + } return { status: 'error', message: formatAdminApiError(err) }; } }; diff --git a/src/lib/event-relays/client-snippet.test.ts b/src/lib/event-relays/client-snippet.test.ts new file mode 100644 index 000000000..f11bc4b03 --- /dev/null +++ b/src/lib/event-relays/client-snippet.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + buildEventRelayClientSnippet, + EVENT_RELAY_DOCS_SNIPPET, +} from './client-snippet.ts'; + +describe('event relay client snippet', () => { + it('uses auth.token and subscribe on connect', () => { + const snippet = buildEventRelayClientSnippet('order-updated'); + assert.match(snippet, /auth:\s*\{\s*token:\s*accessToken\s*\}/); + assert.match(snippet, /socket\.on\('connect',\s*\(\)\s*=>\s*\{/); + assert.match(snippet, /socket\.emit\('subscribe',\s*relayId,\s*resourceId\)/); + assert.match(snippet, /socket\.on\('order-updated',\s*payload\s*=>\s*\{\}\)/); + }); + + it('does not use extraHeaders or a live-path unsubscribe', () => { + for (const snippet of [ + buildEventRelayClientSnippet('x'), + EVENT_RELAY_DOCS_SNIPPET, + ]) { + assert.equal(snippet.includes('extraHeaders'), false); + assert.equal(snippet.includes("emit('unsubscribe'"), false); + } + }); +}); diff --git a/src/lib/event-relays/client-snippet.ts b/src/lib/event-relays/client-snippet.ts index 228132f94..aa5eef912 100644 --- a/src/lib/event-relays/client-snippet.ts +++ b/src/lib/event-relays/client-snippet.ts @@ -7,8 +7,7 @@ export function buildEventRelayClientSnippet(socketEvent: string): string { socket.on('connect', () => { socket.emit('subscribe', relayId, resourceId); }); -socket.on('${eventHandler}', payload => {}); -socket.emit('unsubscribe', relayId, resourceId);`; +socket.on('${eventHandler}', payload => {});`; } export const EVENT_RELAY_DOCS_SNIPPET = buildEventRelayClientSnippet( diff --git a/src/lib/event-relays/preview-remote.test.ts b/src/lib/event-relays/preview-remote.test.ts new file mode 100644 index 000000000..dd61dc017 --- /dev/null +++ b/src/lib/event-relays/preview-remote.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + buildEventRelayPreviewRequestBody, + parseEventRelayPreviewResponse, +} from './preview-remote.ts'; + +describe('event relay preview remote contract', () => { + it('builds the Admin API request body', () => { + const body = buildEventRelayPreviewRequestBody({ + messageTemplate: { id: '{{payload.documentId}}' }, + samplePayload: { documentId: 'abc' }, + }); + assert.deepEqual(body, { + messageTemplate: { id: '{{payload.documentId}}' }, + samplePayload: { documentId: 'abc' }, + }); + assert.equal('template' in body, false); + assert.equal('sample' in body, false); + }); + + it('unwraps rendered from the Admin API response', () => { + const rendered = parseEventRelayPreviewResponse({ + rendered: { id: 'abc', status: 'paid' }, + }); + assert.deepEqual(rendered, { id: 'abc', status: 'paid' }); + }); + + it('rejects responses without rendered', () => { + assert.throws(() => parseEventRelayPreviewResponse({ payload: {} })); + assert.throws(() => parseEventRelayPreviewResponse(null)); + }); +}); diff --git a/src/lib/event-relays/preview-remote.ts b/src/lib/event-relays/preview-remote.ts new file mode 100644 index 000000000..dd3678444 --- /dev/null +++ b/src/lib/event-relays/preview-remote.ts @@ -0,0 +1,35 @@ +export type EventRelayPreviewInput = { + messageTemplate: unknown; + samplePayload: unknown; +}; + +export type EventRelayPreviewRequestBody = { + messageTemplate: unknown; + samplePayload: unknown; +}; + +export type EventRelayPreviewResponseBody = { + rendered: unknown; +}; + +export function buildEventRelayPreviewRequestBody( + input: EventRelayPreviewInput +): EventRelayPreviewRequestBody { + return { + messageTemplate: input.messageTemplate, + samplePayload: input.samplePayload, + }; +} + +export function parseEventRelayPreviewResponse( + data: unknown +): EventRelayPreviewResponseBody['rendered'] { + if (data === null || typeof data !== 'object' || Array.isArray(data)) { + throw new Error('Preview response is invalid'); + } + const record = data as Record; + if (!('rendered' in record)) { + throw new Error('Preview response is missing rendered'); + } + return record.rendered; +} diff --git a/src/lib/models/Router.ts b/src/lib/models/Router.ts index cedf37689..8cae38279 100644 --- a/src/lib/models/Router.ts +++ b/src/lib/models/Router.ts @@ -119,9 +119,9 @@ export type EventRelayWriteRequest = { messageTemplate: unknown; }; -export type EventRelayPreviewRequest = { - template: unknown; - sample: unknown; +export type EventRelayPreviewInput = { + messageTemplate: unknown; + samplePayload: unknown; }; export type EventRelayPreviewRemoteResult = From a2e2a10096e74c575cb9bdb5d6afd19f83023e24 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 18:22:04 +0000 Subject: [PATCH 3/4] fix(router): seed edit sample by resourceIdPath, unwrap 404 Derive preview sample JSON from the relay resourceIdPath on edit, walk error.cause in isAxiosNotFoundError for RSC-wrapped axios failures, and add contract tests for sample payload and 404 detection. Co-authored-by: Konstantinos Kopanidis --- package.json | 2 +- .../router/event-relays/event-relay-form.tsx | 12 ++++++---- .../router/event-relays/event-relay-list.tsx | 1 - src/lib/event-relays/sample-payload.test.ts | 17 ++++++++++++++ src/lib/event-relays/sample-payload.ts | 10 ++++++++ src/lib/logic/api-error.test.ts | 17 ++++++++++++++ src/lib/logic/api-error.ts | 23 +++++++++++++++++-- 7 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 src/lib/event-relays/sample-payload.test.ts create mode 100644 src/lib/event-relays/sample-payload.ts create mode 100644 src/lib/logic/api-error.test.ts diff --git a/package.json b/package.json index 8a35e2cc2..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/path.test.ts src/lib/event-relays/client-snippet.test.ts src/lib/event-relays/preview-remote.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/components/router/event-relays/event-relay-form.tsx b/src/components/router/event-relays/event-relay-form.tsx index 143b33c45..f2eb40d49 100644 --- a/src/components/router/event-relays/event-relay-form.tsx +++ b/src/components/router/event-relays/event-relay-form.tsx @@ -16,10 +16,12 @@ import { 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.documentId}}"\n}'; -const DEFAULT_SAMPLE = - '{\n "documentId": "64f1c0a2b4d0e1f2a3b4c5d6",\n "status": "paid"\n}'; interface EventRelayFormProps { relay?: EventRelay | null; @@ -50,7 +52,9 @@ export function EventRelayForm({ messageTemplate: relay ? JSON.stringify(relay.messageTemplate, null, 2) : DEFAULT_TEMPLATE, - samplePayload: DEFAULT_SAMPLE, + samplePayload: relay + ? buildDefaultSamplePayload(relay.resourceIdPath) + : DEFAULT_CREATE_SAMPLE_PAYLOAD, }, }); @@ -140,7 +144,7 @@ export function EventRelayForm({ fieldName="samplePayload" label="Sample payload" language="json" - placeholder={DEFAULT_SAMPLE} + placeholder={DEFAULT_CREATE_SAMPLE_PAYLOAD} />

    Preview

    diff --git a/src/components/router/event-relays/event-relay-list.tsx b/src/components/router/event-relays/event-relay-list.tsx index 6e3d68577..adf48ab45 100644 --- a/src/components/router/event-relays/event-relay-list.tsx +++ b/src/components/router/event-relays/event-relay-list.tsx @@ -44,7 +44,6 @@ import { useRouter } from 'next/navigation'; interface EventRelayListProps { relays: EventRelay[]; count: number; - /** Undefined when Router settings could not be loaded. */ socketsEnabled?: boolean; } diff --git a/src/lib/event-relays/sample-payload.test.ts b/src/lib/event-relays/sample-payload.test.ts new file mode 100644 index 000000000..476fa6cba --- /dev/null +++ b/src/lib/event-relays/sample-payload.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { buildDefaultSamplePayload } from './sample-payload.ts'; + +describe('default event relay sample payload', () => { + it('uses documentId for database realtime paths', () => { + const json = buildDefaultSamplePayload('documentId'); + assert.match(json, /"documentId"/); + assert.doesNotMatch(json, /"_id"/); + }); + + it('uses _id for CRUD bus paths', () => { + const json = buildDefaultSamplePayload('_id'); + assert.match(json, /"_id"/); + assert.doesNotMatch(json, /"documentId"/); + }); +}); diff --git a/src/lib/event-relays/sample-payload.ts b/src/lib/event-relays/sample-payload.ts new file mode 100644 index 000000000..5ff16a7db --- /dev/null +++ b/src/lib/event-relays/sample-payload.ts @@ -0,0 +1,10 @@ +const SAMPLE_ID = '64f1c0a2b4d0e1f2a3b4c5d6'; + +export function buildDefaultSamplePayload(resourceIdPath: string): string { + const path = resourceIdPath.trim() || 'documentId'; + const key = path.split('.')[0]; + return JSON.stringify({ [key]: SAMPLE_ID, status: 'paid' }, null, 2); +} + +export const DEFAULT_CREATE_SAMPLE_PAYLOAD = + buildDefaultSamplePayload('documentId'); diff --git a/src/lib/logic/api-error.test.ts b/src/lib/logic/api-error.test.ts new file mode 100644 index 000000000..a00a0645c --- /dev/null +++ b/src/lib/logic/api-error.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { isAxiosNotFoundError } from './api-error.ts'; + +describe('isAxiosNotFoundError', () => { + it('detects 404 on the error or its cause chain', () => { + const axios404 = { response: { status: 404 } }; + assert.equal(isAxiosNotFoundError(axios404), true); + assert.equal( + isAxiosNotFoundError( + Object.assign(new Error('Server Components render'), { cause: axios404 }) + ), + true + ); + assert.equal(isAxiosNotFoundError({ response: { status: 500 } }), false); + }); +}); diff --git a/src/lib/logic/api-error.ts b/src/lib/logic/api-error.ts index c95fc1508..ce7877327 100644 --- a/src/lib/logic/api-error.ts +++ b/src/lib/logic/api-error.ts @@ -10,9 +10,28 @@ export function isAxiosLikeError(err: unknown): err is AxiosLikeError { return Boolean(err && typeof err === 'object' && 'response' in err); } +function collectErrorChain(err: unknown): unknown[] { + const chain: unknown[] = []; + const seen = new Set(); + let current: unknown = err; + while (current !== undefined && current !== null && !seen.has(current)) { + seen.add(current); + chain.push(current); + if (typeof current !== 'object' || !('cause' in current)) { + break; + } + current = (current as { cause?: unknown }).cause; + } + return chain; +} + export function getAxiosResponseStatus(err: unknown): number | undefined { - if (!isAxiosLikeError(err)) return undefined; - return err.response?.status; + for (const candidate of collectErrorChain(err)) { + if (!isAxiosLikeError(candidate)) continue; + const status = candidate.response?.status; + if (status !== undefined) return status; + } + return undefined; } export function isAxiosNotFoundError(err: unknown): boolean { From 242e60ebbcb62405ffb4b54920c0b2deeda3ce44 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 18:26:59 +0000 Subject: [PATCH 4/4] fix(router): rethrow session redirect from preview API Extract preview error coercion with isNextNavigationError so debounced preview does not swallow NEXT_REDIRECT as a field error. Co-authored-by: Konstantinos Kopanidis --- src/lib/api/router/index.ts | 13 ++---------- src/lib/event-relays/preview-remote.test.ts | 18 +++++++++++++++++ src/lib/event-relays/preview-remote.ts | 22 +++++++++++++++++++++ 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/lib/api/router/index.ts b/src/lib/api/router/index.ts index 241d74a9d..bc22dadc5 100644 --- a/src/lib/api/router/index.ts +++ b/src/lib/api/router/index.ts @@ -8,12 +8,9 @@ import { EventRelayWriteRequest, RouterSettings, } from '@/lib/models/Router'; -import { - formatAdminApiError, - isAxiosNotFoundError, -} from '@/lib/logic/api-error'; import { buildEventRelayPreviewRequestBody, + coerceEventRelayPreviewRemoteError, parseEventRelayPreviewResponse, } from '@/lib/event-relays/preview-remote'; import { afterPatchServing } from '@/lib/api/modules/afterPatchServing'; @@ -155,12 +152,6 @@ export const previewEventRelayRemote = async ( const payload = parseEventRelayPreviewResponse(res.data); return { status: 'ok', payload }; } catch (err) { - if (isAxiosNotFoundError(err)) { - return { status: 'unavailable' }; - } - if (err instanceof Error && err.message.startsWith('Preview response')) { - return { status: 'error', message: err.message }; - } - return { status: 'error', message: formatAdminApiError(err) }; + return coerceEventRelayPreviewRemoteError(err); } }; diff --git a/src/lib/event-relays/preview-remote.test.ts b/src/lib/event-relays/preview-remote.test.ts index dd61dc017..b6e5a365f 100644 --- a/src/lib/event-relays/preview-remote.test.ts +++ b/src/lib/event-relays/preview-remote.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { buildEventRelayPreviewRequestBody, + coerceEventRelayPreviewRemoteError, parseEventRelayPreviewResponse, } from './preview-remote.ts'; @@ -30,4 +31,21 @@ describe('event relay preview remote contract', () => { assert.throws(() => parseEventRelayPreviewResponse({ payload: {} })); assert.throws(() => parseEventRelayPreviewResponse(null)); }); + + it('rethrows Next navigation errors for session redirect', () => { + const redirectErr = Object.assign(new Error('NEXT_REDIRECT'), { + digest: 'NEXT_REDIRECT;replace;/login?session-timeout=true', + }); + assert.throws( + () => coerceEventRelayPreviewRemoteError(redirectErr), + redirectErr + ); + }); + + it('maps axios 404 to unavailable', () => { + const result = coerceEventRelayPreviewRemoteError({ + response: { status: 404 }, + }); + assert.deepEqual(result, { status: 'unavailable' }); + }); }); diff --git a/src/lib/event-relays/preview-remote.ts b/src/lib/event-relays/preview-remote.ts index dd3678444..6ac141e9d 100644 --- a/src/lib/event-relays/preview-remote.ts +++ b/src/lib/event-relays/preview-remote.ts @@ -1,3 +1,10 @@ +import { + formatAdminApiError, + isAxiosNotFoundError, + isNextNavigationError, +} from '../logic/api-error.ts'; +import type { EventRelayPreviewRemoteResult } from '../models/Router.ts'; + export type EventRelayPreviewInput = { messageTemplate: unknown; samplePayload: unknown; @@ -33,3 +40,18 @@ export function parseEventRelayPreviewResponse( } return record.rendered; } + +export function coerceEventRelayPreviewRemoteError( + err: unknown +): EventRelayPreviewRemoteResult { + if (isNextNavigationError(err)) { + throw err; + } + if (isAxiosNotFoundError(err)) { + return { status: 'unavailable' }; + } + if (err instanceof Error && err.message.startsWith('Preview response')) { + return { status: 'error', message: err.message }; + } + return { status: 'error', message: formatAdminApiError(err) }; +}