@@ -22,6 +22,75 @@ interface RouteDefinition {
2222 handler : ( req : any ) => Promise < any > ;
2323}
2424
25+ /**
26+ * Register a single RouteDefinition on the HTTP server.
27+ * Returns true if the route was successfully registered.
28+ */
29+ function mountRouteOnServer ( route : RouteDefinition , server : IHttpServer , routePath : string ) : boolean {
30+ const handler = async ( req : any , res : any ) => {
31+ try {
32+ const result = await route . handler ( {
33+ body : req . body ,
34+ params : req . params ,
35+ query : req . query ,
36+ } ) ;
37+
38+ if ( result . stream && result . events ) {
39+ // SSE streaming response
40+ res . status ( result . status ) ;
41+
42+ // Apply headers from the route result if available
43+ if ( result . headers ) {
44+ for ( const [ k , v ] of Object . entries ( result . headers ) ) {
45+ res . header ( k , String ( v ) ) ;
46+ }
47+ } else {
48+ res . header ( 'Content-Type' , 'text/event-stream' ) ;
49+ res . header ( 'Cache-Control' , 'no-cache' ) ;
50+ res . header ( 'Connection' , 'keep-alive' ) ;
51+ }
52+
53+ // Write the stream — events are pre-encoded SSE strings
54+ if ( typeof res . write === 'function' && typeof res . end === 'function' ) {
55+ for await ( const event of result . events ) {
56+ res . write ( typeof event === 'string' ? event : `data: ${ JSON . stringify ( event ) } \n\n` ) ;
57+ }
58+ res . end ( ) ;
59+ } else {
60+ // Fallback: collect events into array
61+ const events = [ ] ;
62+ for await ( const event of result . events ) {
63+ events . push ( event ) ;
64+ }
65+ res . json ( { events } ) ;
66+ }
67+ } else {
68+ res . status ( result . status ) ;
69+ if ( result . body !== undefined ) {
70+ res . json ( result . body ) ;
71+ } else {
72+ res . end ( ) ;
73+ }
74+ }
75+ } catch ( err : any ) {
76+ errorResponse ( err , res ) ;
77+ }
78+ } ;
79+
80+ const m = route . method . toLowerCase ( ) ;
81+ if ( m === 'get' && typeof server . get === 'function' ) {
82+ server . get ( routePath , handler ) ;
83+ return true ;
84+ } else if ( m === 'post' && typeof server . post === 'function' ) {
85+ server . post ( routePath , handler ) ;
86+ return true ;
87+ } else if ( m === 'delete' && typeof server . delete === 'function' ) {
88+ server . delete ( routePath , handler ) ;
89+ return true ;
90+ }
91+ return false ;
92+ }
93+
2594/**
2695 * Send an HttpDispatcherResult through IHttpResponse.
2796 * Differentiates between handled, unhandled (404), and special results.
@@ -402,68 +471,33 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
402471 const routePath = route . path . startsWith ( '/api/v1' )
403472 ? route . path
404473 : `${ prefix } ${ route . path } ` ;
405-
406- const handler = async ( req : any , res : any ) => {
407- try {
408- const result = await route . handler ( {
409- body : req . body ,
410- params : req . params ,
411- query : req . query ,
412- } ) ;
413-
414- if ( result . stream && result . events ) {
415- // SSE streaming response
416- res . status ( result . status ) ;
417-
418- // Apply headers from the route result if available
419- if ( result . headers ) {
420- for ( const [ k , v ] of Object . entries ( result . headers ) ) {
421- res . header ( k , v ) ;
422- }
423- } else {
424- res . header ( 'Content-Type' , 'text/event-stream' ) ;
425- res . header ( 'Cache-Control' , 'no-cache' ) ;
426- res . header ( 'Connection' , 'keep-alive' ) ;
427- }
428-
429- // Write the stream — events are pre-encoded SSE strings
430- if ( typeof res . write === 'function' && typeof res . end === 'function' ) {
431- for await ( const event of result . events ) {
432- res . write ( typeof event === 'string' ? event : `data: ${ JSON . stringify ( event ) } \n\n` ) ;
433- }
434- res . end ( ) ;
435- } else {
436- // Fallback: collect events into array
437- const events = [ ] ;
438- for await ( const event of result . events ) {
439- events . push ( event ) ;
440- }
441- res . json ( { events } ) ;
442- }
443- } else {
444- res . status ( result . status ) ;
445- if ( result . body !== undefined ) {
446- res . json ( result . body ) ;
447- } else {
448- res . end ( ) ;
449- }
450- }
451- } catch ( err : any ) {
452- errorResponse ( err , res ) ;
453- }
454- } ;
455-
456- const m = route . method . toLowerCase ( ) ;
457- if ( m === 'get' && typeof server . get === 'function' ) {
458- server . get ( routePath , handler ) ;
459- } else if ( m === 'post' && typeof server . post === 'function' ) {
460- server . post ( routePath , handler ) ;
461- } else if ( m === 'delete' && typeof server . delete === 'function' ) {
462- server . delete ( routePath , handler ) ;
463- }
474+ mountRouteOnServer ( route , server , routePath ) ;
464475 }
465476 ctx . logger . info ( `[Dispatcher] Registered ${ routes . length } AI routes` ) ;
466477 } ) ;
478+
479+ // ── Fallback: recover routes cached before hook was registered ──
480+ // If AIServicePlugin.start() ran before DispatcherPlugin.start()
481+ // (possible when plugin start order differs from registration order),
482+ // the 'ai:routes' trigger fires with no listener. The AIServicePlugin
483+ // caches the routes on the kernel as __aiRoutes (see AIServicePlugin.start())
484+ // as an internal cross-plugin protocol so we can recover them here.
485+ // TODO: replace with a formal kernel.getCachedRoutes('ai') API in a future release.
486+ const cachedRoutes = ( kernel as any ) . __aiRoutes as RouteDefinition [ ] | undefined ;
487+ if ( cachedRoutes && Array . isArray ( cachedRoutes ) && cachedRoutes . length > 0 ) {
488+ let registered = 0 ;
489+ for ( const route of cachedRoutes ) {
490+ const routePath = route . path . startsWith ( '/api/v1' )
491+ ? route . path
492+ : `${ prefix } ${ route . path } ` ;
493+ if ( mountRouteOnServer ( route , server , routePath ) ) {
494+ registered ++ ;
495+ }
496+ }
497+ if ( registered > 0 ) {
498+ ctx . logger . info ( `[Dispatcher] Recovered ${ registered } cached AI routes (hook timing fallback)` ) ;
499+ }
500+ }
467501 } ,
468502 } ;
469503}
0 commit comments