@@ -23,13 +23,62 @@ import { mapNostrAmbEventToExternalOerItem } from './mappers/nostr-amb-to-extern
2323/** Default timeout for relay requests */
2424const DEFAULT_TIMEOUT_MS = 10000 ;
2525
26+ /** Maximum number of relay URLs allowed to prevent resource exhaustion */
27+ const MAX_RELAY_COUNT = 10 ;
28+
29+ /**
30+ * Maps UI resource types to HCRT (Hochschulcurriculare Ressourcentypen) vocabulary entries.
31+ *
32+ * The relay groups filter tokens by base field name (before the first dot) and
33+ * OR's values within the same group. Using `learningResourceType.id` and
34+ * `learningResourceType.prefLabel.en` ensures they share the `learningResourceType`
35+ * group and are OR'd together, matching resources tagged with either the HCRT URI
36+ * or the English label.
37+ *
38+ * @see https://w3id.org/kim/hcrt/scheme
39+ */
40+ interface TypeFilterTokens {
41+ readonly hcrtId : string ;
42+ readonly hcrtPrefLabelEn : string ;
43+ }
44+
45+ const TYPE_FILTER_CONFIG : Readonly < Record < string , TypeFilterTokens > > = {
46+ image : {
47+ hcrtId : 'https://w3id.org/kim/hcrt/image' ,
48+ hcrtPrefLabelEn : 'Image' ,
49+ } ,
50+ video : {
51+ hcrtId : 'https://w3id.org/kim/hcrt/video' ,
52+ hcrtPrefLabelEn : 'Video' ,
53+ } ,
54+ audio : {
55+ hcrtId : 'https://w3id.org/kim/hcrt/audio' ,
56+ hcrtPrefLabelEn : 'Audio' ,
57+ } ,
58+ text : {
59+ hcrtId : 'https://w3id.org/kim/hcrt/text' ,
60+ hcrtPrefLabelEn : 'Text' ,
61+ } ,
62+ 'application/pdf' : {
63+ hcrtId : 'https://w3id.org/kim/hcrt/text' ,
64+ hcrtPrefLabelEn : 'Text' ,
65+ } ,
66+ } ;
67+
68+ interface RelayQueryResults {
69+ readonly events : readonly Event [ ] ;
70+ readonly errors : readonly Error [ ] ;
71+ }
72+
2673/**
2774 * Nostr AMB Relay adapter for searching educational metadata.
2875 *
2976 * The amb-relay is a specialized search relay for educational metadata
3077 * built on the AMB (Allgemeines Metadatenprofil für Bildungsressourcen) standard.
3178 * It combines Typesense full-text search with the Nostr protocol.
3279 *
80+ * Supports multiple relay URLs for fan-out queries with result merging and deduplication.
81+ *
3382 * @see https://github.com/edufeed-org/amb-relay
3483 */
3584export class NostrAmbRelayAdapter implements SourceAdapter {
@@ -41,26 +90,36 @@ export class NostrAmbRelayAdapter implements SourceAdapter {
4190 supportsEducationalLevelFilter : true ,
4291 } ;
4392
44- private readonly relayUrl : string ;
93+ private readonly relayUrls : readonly string [ ] ;
4594 private readonly timeoutMs : number ;
4695
4796 constructor ( config : NostrAmbRelayConfig ) {
48- this . relayUrl = config . relayUrl ;
97+ if ( config . relayUrls . length === 0 ) {
98+ throw new Error ( 'At least one relay URL must be provided' ) ;
99+ }
100+ if ( config . relayUrls . length > MAX_RELAY_COUNT ) {
101+ throw new Error (
102+ `Too many relay URLs (${ config . relayUrls . length } ). Maximum is ${ MAX_RELAY_COUNT } .` ,
103+ ) ;
104+ }
105+ const validatedUrls = config . relayUrls . map ( ( url ) => {
106+ if ( ! url . startsWith ( 'ws://' ) && ! url . startsWith ( 'wss://' ) ) {
107+ throw new Error (
108+ `Invalid relay URL scheme: ${ url } . Must use ws:// or wss://` ,
109+ ) ;
110+ }
111+ return url ;
112+ } ) ;
113+ this . relayUrls = validatedUrls ;
49114 this . timeoutMs = config . timeoutMs ?? DEFAULT_TIMEOUT_MS ;
50115 }
51116
52117 /**
53118 * Search for educational resources matching the query.
54119 *
55- * The amb-relay supports full-text search through the Nostr protocol
56- * using the 'search' filter parameter.
57- *
58- * Supported filters:
59- * - search: Full-text search across all AMB metadata fields
60- * - limit: Number of results to return (maps to pageSize)
61- *
62- * Field-specific queries can be performed using the format:
63- * "field.subfield:value" (e.g., "publisher.name:example")
120+ * Fans out to all configured relays concurrently. Results are merged and
121+ * deduplicated by event ID. Partial results are returned if some relays fail;
122+ * an error is thrown only when all relays fail.
64123 */
65124 async search (
66125 query : AdapterSearchQuery ,
@@ -70,28 +129,46 @@ export class NostrAmbRelayAdapter implements SourceAdapter {
70129 return EMPTY_RESULT ;
71130 }
72131
73- let relay : Relay | null = null ;
132+ const filter = this . buildFilter ( query ) ;
133+ const { events, errors } = await this . queryAllRelays (
134+ filter ,
135+ options ?. signal ,
136+ ) ;
74137
75- try {
76- relay = await Relay . connect ( this . relayUrl ) ;
138+ if ( events . length === 0 && errors . length > 0 ) {
139+ throw this . wrapError ( errors [ 0 ] ) ;
140+ }
77141
78- const filter = this . buildFilter ( query ) ;
79- const events = await this . subscribeToEvents (
80- relay ,
81- filter ,
82- options ?. signal ,
83- ) ;
84- const items = this . mapEventsToItems ( events ) ;
85- const paginatedItems = paginateItems ( items , query . page , query . pageSize ) ;
142+ const deduplicatedEvents = deduplicateEvents ( events ) ;
143+ const items = this . mapEventsToItems ( deduplicatedEvents ) ;
144+ const paginatedItems = paginateItems ( items , query . page , query . pageSize ) ;
86145
87- return {
88- items : paginatedItems ,
89- total : events . length ,
90- } ;
91- } catch ( error ) {
92- throw this . wrapError ( error ) ;
146+ return {
147+ items : paginatedItems ,
148+ total : deduplicatedEvents . length ,
149+ } ;
150+ }
151+
152+ private async queryAllRelays (
153+ filter : Filter ,
154+ signal ?: AbortSignal ,
155+ ) : Promise < RelayQueryResults > {
156+ const settled = await Promise . allSettled (
157+ this . relayUrls . map ( ( url ) => this . queryRelay ( url , filter , signal ) ) ,
158+ ) ;
159+ return collectResults ( settled ) ;
160+ }
161+
162+ private async queryRelay (
163+ url : string ,
164+ filter : Filter ,
165+ signal ?: AbortSignal ,
166+ ) : Promise < Event [ ] > {
167+ const relay = await Relay . connect ( url ) ;
168+ try {
169+ return await this . subscribeToEvents ( relay , filter , signal ) ;
93170 } finally {
94- relay ? .close ( ) ;
171+ relay . close ( ) ;
95172 }
96173 }
97174
@@ -102,9 +179,7 @@ export class NostrAmbRelayAdapter implements SourceAdapter {
102179 * and converts them to Typesense filter_by expressions. This allows
103180 * filtering by language, license, and type without protocol-level changes.
104181 */
105- private buildFilter (
106- query : AdapterSearchQuery ,
107- ) : Filter & { search ?: string } {
182+ private buildFilter ( query : AdapterSearchQuery ) : Filter & { search ?: string } {
108183 const searchParts : string [ ] = [ query . keywords ?. trim ( ) ?? '' ] ;
109184
110185 if ( query . language ) {
@@ -116,7 +191,13 @@ export class NostrAmbRelayAdapter implements SourceAdapter {
116191 }
117192
118193 if ( query . type ) {
119- searchParts . push ( `type:${ query . type } ` ) ;
194+ const config = TYPE_FILTER_CONFIG [ query . type ] ;
195+ if ( config ) {
196+ searchParts . push ( `learningResourceType.id:${ config . hcrtId } ` ) ;
197+ searchParts . push (
198+ `learningResourceType.prefLabel.en:${ config . hcrtPrefLabelEn } ` ,
199+ ) ;
200+ }
120201 }
121202
122203 if ( query . educationalLevel ) {
@@ -138,22 +219,33 @@ export class NostrAmbRelayAdapter implements SourceAdapter {
138219 const events : Event [ ] = [ ] ;
139220
140221 await new Promise < void > ( ( resolve , reject ) => {
222+ let sub : { close : ( ) => void } | undefined ;
223+
224+ const cleanup = ( ) => {
225+ sub ?. close ( ) ;
226+ } ;
227+
141228 const timeoutId = setTimeout ( ( ) => {
229+ cleanup ( ) ;
142230 reject ( new Error ( `Relay request timed out after ${ this . timeoutMs } ms` ) ) ;
143231 } , this . timeoutMs ) ;
144232
145- signal ?. addEventListener ( 'abort' , ( ) => {
233+ const onAbort = ( ) => {
146234 clearTimeout ( timeoutId ) ;
235+ cleanup ( ) ;
147236 reject ( new Error ( 'Request aborted' ) ) ;
148- } ) ;
237+ } ;
238+
239+ signal ?. addEventListener ( 'abort' , onAbort , { once : true } ) ;
149240
150- const sub = relay . subscribe ( [ filter ] , {
241+ sub = relay . subscribe ( [ filter ] , {
151242 onevent : ( event : Event ) => {
152243 events . push ( event ) ;
153244 } ,
154245 oneose : ( ) => {
155246 clearTimeout ( timeoutId ) ;
156- sub . close ( ) ;
247+ signal ?. removeEventListener ( 'abort' , onAbort ) ;
248+ sub ?. close ( ) ;
157249 resolve ( ) ;
158250 } ,
159251 } ) ;
@@ -183,6 +275,35 @@ export class NostrAmbRelayAdapter implements SourceAdapter {
183275 }
184276}
185277
278+ function deduplicateEvents ( events : readonly Event [ ] ) : Event [ ] {
279+ return Array . from (
280+ events
281+ . reduce (
282+ ( seen , event ) => ( seen . has ( event . id ) ? seen : seen . set ( event . id , event ) ) ,
283+ new Map < string , Event > ( ) ,
284+ )
285+ . values ( ) ,
286+ ) ;
287+ }
288+
289+ function collectResults (
290+ settled : readonly PromiseSettledResult < Event [ ] > [ ] ,
291+ ) : RelayQueryResults {
292+ const fulfilled = settled . filter (
293+ ( r ) : r is PromiseFulfilledResult < Event [ ] > => r . status === 'fulfilled' ,
294+ ) ;
295+ const rejected = settled . filter (
296+ ( r ) : r is PromiseRejectedResult => r . status === 'rejected' ,
297+ ) ;
298+
299+ return {
300+ events : fulfilled . flatMap ( ( r ) => r . value ) ,
301+ errors : rejected . map ( ( r ) =>
302+ r . reason instanceof Error ? r . reason : new Error ( String ( r . reason ) ) ,
303+ ) ,
304+ } ;
305+ }
306+
186307/**
187308 * Factory function to create a NostrAmbRelayAdapter.
188309 *
0 commit comments