@@ -65,6 +65,10 @@ const GnosisServer = struct {
6565 stop_flag : std .atomic .Value (bool ),
6666 /// gnosis binary path (null-terminated, heap-owned by pool allocator).
6767 gnosis_bin : [:0 ]const u8 ,
68+ /// Optional edge handler hook. When non-null, `uapi_gnosis_start`'s serve
69+ /// loop calls this instead of the built-in gnosis route handlers.
70+ /// Set once before `uapi_gnosis_start`; never changed after start.
71+ handler : ? GnosisHandlerFn ,
6872
6973 /// Return a zeroed-out default slot.
7074 fn empty () GnosisServer {
@@ -75,10 +79,63 @@ const GnosisServer = struct {
7579 .thread = null ,
7680 .stop_flag = std .atomic .Value (bool ).init (false ),
7781 .gnosis_bin = "" ,
82+ .handler = null ,
7883 };
7984 }
8085};
8186
87+ // =============================================================================
88+ // Handler hook types
89+ // =============================================================================
90+
91+ /// C-ABI-stable request context passed to edge handler functions.
92+ ///
93+ /// `method` and `path` are null-terminated C strings pointing into the
94+ /// per-connection buffer — valid only for the duration of the handler call.
95+ /// `body_ptr` / `body_len` describe the request body (empty slice for GET).
96+ /// `conn` is an opaque pointer to the underlying `std.net.Server.Connection`;
97+ /// cast to `*std.net.Server.Connection` inside the handler if needed.
98+ /// Prefer the provided helper `uapi_gnosis_write_response` instead of
99+ /// reaching into the connection directly.
100+ pub const GnosisRequest = extern struct {
101+ /// HTTP method string, e.g. "GET", "POST" (null-terminated).
102+ method : [* :0 ]const u8 ,
103+ /// Request path, e.g. "/api/v1/render" (null-terminated, query-stripped).
104+ path : [* :0 ]const u8 ,
105+ /// Request body bytes. Null pointer when body is empty.
106+ body_ptr : ? [* ]const u8 ,
107+ /// Byte length of `body_ptr` (0 when body is empty).
108+ body_len : u32 ,
109+ };
110+
111+ /// C-ABI response written by an edge handler.
112+ ///
113+ /// The handler fills in all four fields; `uapi_gnosis_start`'s serve loop
114+ /// flushes them to the TCP stream.
115+ /// `body_ptr` must remain valid until `uapi_gnosis_write_response` returns
116+ /// (stack buffers inside the handler are fine).
117+ pub const GnosisResponse = extern struct {
118+ /// HTTP numeric status code, e.g. 200, 404.
119+ status : u16 ,
120+ _pad : u16 ,
121+ /// Null-terminated MIME type string, e.g. "application/json".
122+ content_type : [* :0 ]const u8 ,
123+ /// Response body bytes. Null pointer for zero-length body.
124+ body_ptr : ? [* ]const u8 ,
125+ /// Byte length of `body_ptr`.
126+ body_len : u32 ,
127+ };
128+
129+ /// C-ABI function pointer type for edge handler hooks.
130+ ///
131+ /// The handler receives a parsed request and an output `GnosisResponse` it
132+ /// must fill before returning. Both pointers are valid for the entire call.
133+ /// The handler MUST NOT store either pointer beyond the call.
134+ pub const GnosisHandlerFn = * const fn (
135+ req : * const GnosisRequest ,
136+ resp : * GnosisResponse ,
137+ ) callconv (.c ) void ;
138+
82139// =============================================================================
83140// Global pool + pool allocator
84141// =============================================================================
@@ -418,6 +475,8 @@ fn readLine(stream: std.net.Stream, buf: []u8) ![]const u8 {
418475const ServeThreadArgs = struct {
419476 idx : usize ,
420477 gnosis_bin : []const u8 ,
478+ /// Optional edge handler — null means use built-in gnosis routes.
479+ handler : ? GnosisHandlerFn ,
421480};
422481
423482fn serveThread (args : ServeThreadArgs ) void {
@@ -459,7 +518,13 @@ fn serveThread(args: ServeThreadArgs) void {
459518 // Reset the arena between connections.
460519 _ = arena .reset (.retain_capacity );
461520
462- serveRequest (& conn , ctx );
521+ if (args .handler ) | handler_fn | {
522+ // Edge handler hook: dispatch to the edge's path-routing function
523+ // instead of the built-in gnosis routes.
524+ serveRequestViaHandler (& conn , handler_fn , ctx .allocator );
525+ } else {
526+ serveRequest (& conn , ctx );
527+ }
463528 }
464529
465530 pool_mutex .lock ();
@@ -490,6 +555,120 @@ fn stopServerSlot(idx: usize) void {
490555 pool [idx ].state = .stopped ;
491556}
492557
558+ // =============================================================================
559+ // Edge handler dispatch
560+ // =============================================================================
561+
562+ /// Parse the HTTP/1.1 request from `conn`, build a `GnosisRequest`, invoke
563+ /// `handler_fn`, then flush the `GnosisResponse` back to the stream.
564+ ///
565+ /// Called from `serveThread` when the pool slot has a registered handler.
566+ /// All allocations go through `allocator`; freed when the arena resets between
567+ /// connections.
568+ fn serveRequestViaHandler (
569+ conn : * std.net.Server.Connection ,
570+ handler_fn : GnosisHandlerFn ,
571+ allocator : std.mem.Allocator ,
572+ ) void {
573+ // --- Parse request line ---
574+ var request_line_buf : [1024 ]u8 = undefined ;
575+ const request_line = readLine (conn .stream , & request_line_buf ) catch {
576+ writeBadRequest (conn , "malformed request line" );
577+ return ;
578+ };
579+
580+ var parts = std .mem .splitScalar (u8 , request_line , ' ' );
581+ const method_str = parts .next () orelse {
582+ writeBadRequest (conn , "missing method" );
583+ return ;
584+ };
585+ const raw_path = parts .next () orelse {
586+ writeBadRequest (conn , "missing path" );
587+ return ;
588+ };
589+
590+ // Strip query string from path.
591+ const path_str = if (std .mem .indexOfScalar (u8 , raw_path , '?' )) | qi |
592+ raw_path [0.. qi ]
593+ else
594+ raw_path ;
595+
596+ // --- Drain headers, extract Content-Length ---
597+ var content_length : usize = 0 ;
598+ var header_buf : [256 ]u8 = undefined ;
599+ while (true ) {
600+ const line = readLine (conn .stream , & header_buf ) catch break ;
601+ if (line .len == 0 ) break ;
602+ if (std .ascii .startsWithIgnoreCase (line , "content-length:" )) {
603+ const val = std .mem .trimLeft (u8 , line ["content-length:" .len .. ], " \t " );
604+ content_length = std .fmt .parseInt (usize , val , 10 ) catch 0 ;
605+ }
606+ }
607+
608+ // --- Read body ---
609+ var body_owned : ? []u8 = null ;
610+ defer if (body_owned ) | b | allocator .free (b );
611+ if (content_length > 0 ) {
612+ body_owned = readBody (allocator , conn .stream , content_length ) catch {
613+ writeInternalError (conn , "failed to read request body" );
614+ return ;
615+ };
616+ }
617+ const body_bytes : ? []u8 = body_owned ;
618+
619+ // --- Null-terminate method and path for the C-ABI structs ---
620+ // Allocate sentinel-terminated copies so the handler sees valid C strings.
621+ const method_z = allocator .dupeZ (u8 , method_str ) catch {
622+ writeInternalError (conn , "oom" );
623+ return ;
624+ };
625+ const path_z = allocator .dupeZ (u8 , path_str ) catch {
626+ writeInternalError (conn , "oom" );
627+ return ;
628+ };
629+
630+ // --- Build GnosisRequest ---
631+ const req = GnosisRequest {
632+ .method = method_z ,
633+ .path = path_z ,
634+ .body_ptr = if (body_bytes ) | b | b .ptr else null ,
635+ .body_len = @intCast (if (body_bytes ) | b | b .len else 0 ),
636+ };
637+
638+ // --- Invoke handler ---
639+ var resp = GnosisResponse {
640+ .status = 200 ,
641+ ._pad = 0 ,
642+ .content_type = "application/json" ,
643+ .body_ptr = null ,
644+ .body_len = 0 ,
645+ };
646+ handler_fn (& req , & resp );
647+
648+ // --- Flush response ---
649+ const body_out : []const u8 = if (resp .body_ptr ) | p | p [0.. resp .body_len ] else "" ;
650+ const ct_out : []const u8 = std .mem .span (resp .content_type );
651+ writeResponse (conn , resp .status , ct_out , body_out );
652+ }
653+
654+ /// Helper exported for edge handlers: write a response body into a
655+ /// `GnosisResponse` from a Zig slice. Since the edge handler stack-allocates
656+ /// a response buffer, this is a convenience shim — the handler can also fill
657+ /// `resp.*` directly.
658+ pub export fn uapi_gnosis_write_response (
659+ resp : * GnosisResponse ,
660+ status : u16 ,
661+ content_type : [* :0 ]const u8 ,
662+ body_ptr : ? [* ]const u8 ,
663+ body_len : u32 ,
664+ ) callconv (.c ) void {
665+ resp .status = status ;
666+ resp ._pad = 0 ;
667+ resp .content_type = content_type ;
668+ resp .body_ptr = body_ptr ;
669+ resp .body_len = body_len ;
670+ }
671+
493672// =============================================================================
494673// Exported C ABI (uapi_gnosis_*)
495674// =============================================================================
@@ -544,7 +723,11 @@ pub export fn uapi_gnosis_start(handle: u64) callconv(.c) u8 {
544723 pool [idx ].thread = std .Thread .spawn (
545724 .{},
546725 serveThread ,
547- .{ServeThreadArgs { .idx = idx , .gnosis_bin = pool [idx ].gnosis_bin }},
726+ .{ServeThreadArgs {
727+ .idx = idx ,
728+ .gnosis_bin = pool [idx ].gnosis_bin ,
729+ .handler = pool [idx ].handler ,
730+ }},
548731 ) catch | err | {
549732 core .setError ("gnosis: thread spawn failed: {}" , .{err });
550733 return core .Result .process_failed .toU8 ();
@@ -585,6 +768,43 @@ pub export fn uapi_gnosis_state(handle: u64) callconv(.c) u8 {
585768 return @intFromEnum (pool [idx ].state );
586769}
587770
771+ /// Register an edge handler hook for the server identified by `handle`.
772+ ///
773+ /// Semantics:
774+ /// - MUST be called after `uapi_gnosis_create` and BEFORE `uapi_gnosis_start`.
775+ /// - Calling after `uapi_gnosis_start` returns `UAPI_ERR` (no hot-swap).
776+ /// - Once set, `uapi_gnosis_start`'s accept loop calls `handler_fn` for every
777+ /// request instead of the built-in gnosis routes.
778+ /// - The built-in routes are still compiled in; pass a null handler (0) to
779+ /// revert to them.
780+ ///
781+ /// Returns UAPI_OK (0) on success, UAPI_ERR (1) on failure.
782+ pub export fn uapi_gnosis_set_handler (
783+ handle : u64 ,
784+ handler_fn : ? GnosisHandlerFn ,
785+ ) callconv (.c ) u8 {
786+ const idx = idxFromHandle (handle ) orelse {
787+ core .setError ("gnosis: set_handler: invalid handle {d}" , .{handle });
788+ return core .Result .invalid_param .toU8 ();
789+ };
790+
791+ pool_mutex .lock ();
792+ defer pool_mutex .unlock ();
793+
794+ if (! pool [idx ].active ) {
795+ core .setError ("gnosis: set_handler: handle {d} is not active" , .{handle });
796+ return core .Result .invalid_param .toU8 ();
797+ }
798+ // Refuse to hot-swap after the server has started.
799+ if (pool [idx ].state == .listening or pool [idx ].state == .draining ) {
800+ core .setError ("gnosis: set_handler: server already started — cannot change handler" , .{});
801+ return core .Result .err .toU8 ();
802+ }
803+
804+ pool [idx ].handler = handler_fn ;
805+ return core .Result .ok .toU8 ();
806+ }
807+
588808/// Synchronous health probe.
589809/// Returns 0 (serving) if the gnosis binary responds, 1 (not_serving) otherwise.
590810pub export fn uapi_gnosis_health (handle : u64 ) callconv (.c ) u8 {
@@ -641,3 +861,77 @@ test "handle encoding roundtrip" {
641861 // handle > MAX_SERVERS is invalid
642862 try std .testing .expectEqual (@as (? usize , null ), idxFromHandle (MAX_SERVERS + 1 ));
643863}
864+
865+ // ---------------------------------------------------------------------------
866+ // Handler hook tests
867+ // ---------------------------------------------------------------------------
868+
869+ /// A no-op edge handler: writes a fixed 200 JSON response.
870+ /// Used by the tests below to verify that the hook dispatch path is reachable.
871+ fn testHandler (req : * const GnosisRequest , resp : * GnosisResponse ) callconv (.c ) void {
872+ // Suppress unused-parameter warning.
873+ _ = req ;
874+ resp .status = 200 ;
875+ resp ._pad = 0 ;
876+ resp .content_type = "application/json" ;
877+ // Point at a static string — valid for the lifetime of the call.
878+ const body : [* :0 ]const u8 = "{\" handler\" :\" test\" }" ;
879+ resp .body_ptr = body ;
880+ resp .body_len = 18 ; // length of the string above
881+ }
882+
883+ test "set_handler rejects invalid handle" {
884+ init ();
885+ defer teardown ();
886+ const rc = uapi_gnosis_set_handler (0 , & testHandler );
887+ try std .testing .expectEqual (@as (u8 , core .Result .invalid_param .toU8 ()), rc );
888+ }
889+
890+ test "set_handler accepts idle server and records handler" {
891+ init ();
892+ defer teardown ();
893+
894+ const handle = uapi_gnosis_create (19876 );
895+ try std .testing .expect (handle != 0 );
896+ defer uapi_gnosis_destroy (handle );
897+
898+ const rc = uapi_gnosis_set_handler (handle , & testHandler );
899+ try std .testing .expectEqual (@as (u8 , core .Result .ok .toU8 ()), rc );
900+
901+ // Verify the handler was stored.
902+ const idx = idxFromHandle (handle ) orelse return error .BadHandle ;
903+ try std .testing .expect (pool [idx ].handler != null );
904+ }
905+
906+ test "set_handler rejects null handler (revert to built-in)" {
907+ init ();
908+ defer teardown ();
909+
910+ const handle = uapi_gnosis_create (19877 );
911+ try std .testing .expect (handle != 0 );
912+ defer uapi_gnosis_destroy (handle );
913+
914+ // Set a real handler, then clear it.
915+ _ = uapi_gnosis_set_handler (handle , & testHandler );
916+ const rc = uapi_gnosis_set_handler (handle , null );
917+ try std .testing .expectEqual (@as (u8 , core .Result .ok .toU8 ()), rc );
918+
919+ const idx = idxFromHandle (handle ) orelse return error .BadHandle ;
920+ try std .testing .expect (pool [idx ].handler == null );
921+ }
922+
923+ test "write_response fills GnosisResponse fields" {
924+ const body = "hello" ;
925+ const ct : [* :0 ]const u8 = "text/plain" ;
926+ var resp = GnosisResponse {
927+ .status = 0 ,
928+ ._pad = 0 ,
929+ .content_type = "application/json" ,
930+ .body_ptr = null ,
931+ .body_len = 0 ,
932+ };
933+ uapi_gnosis_write_response (& resp , 201 , ct , body , 5 );
934+ try std .testing .expectEqual (@as (u16 , 201 ), resp .status );
935+ try std .testing .expectEqual (@as (u32 , 5 ), resp .body_len );
936+ try std .testing .expectEqual (@as (? [* ]const u8 , body ), resp .body_ptr );
937+ }
0 commit comments