@@ -40,6 +40,8 @@ const JWT_REGEX = /^[a-zA-Z0-9\-_=]+?\.[a-zA-Z0-9\-_=]+?\.([a-zA-Z0-9\-_=]+)?$/;
40
40
export const CALLABLE_AUTH_HEADER = "x-callable-context-auth" ;
41
41
/** @internal */
42
42
export const ORIGINAL_AUTH_HEADER = "x-original-auth" ;
43
+ /** @internal */
44
+ export const DEFAULT_HEARTBEAT_SECONDS = 30 ;
43
45
44
46
/** An express request with the wire format representation of the request body. */
45
47
export interface Request extends express . Request {
@@ -146,8 +148,21 @@ export interface CallableRequest<T = any> {
146
148
* to allow writing partial, streaming responses back to the client.
147
149
*/
148
150
export interface CallableProxyResponse {
151
+ /**
152
+ * Writes a chunk of the response body to the client. This method can be called
153
+ * multiple times to stream data progressively.
154
+ */
149
155
write : express . Response [ "write" ] ;
156
+ /**
157
+ * Indicates whether the client has requested and can handle streaming responses.
158
+ * This should be checked before attempting to stream data to avoid compatibility issues.
159
+ */
150
160
acceptsStreaming : boolean ;
161
+ /**
162
+ * An AbortSignal that is triggered when the client disconnects or the
163
+ * request is terminated prematurely.
164
+ */
165
+ signal : AbortSignal ;
151
166
}
152
167
153
168
/**
@@ -692,6 +707,13 @@ export interface CallableOptions {
692
707
cors : cors . CorsOptions ;
693
708
enforceAppCheck ?: boolean ;
694
709
consumeAppCheckToken ?: boolean ;
710
+ /**
711
+ * Time in seconds between sending heartbeat messages to keep the connection
712
+ * alive. Set to `null` to disable heartbeats.
713
+ *
714
+ * Defaults to 30 seconds.
715
+ */
716
+ heartbeatSeconds ?: number | null
695
717
}
696
718
697
719
/** @internal */
@@ -722,6 +744,22 @@ function wrapOnCallHandler<Req = any, Res = any>(
722
744
version : "gcfv1" | "gcfv2"
723
745
) : ( req : Request , res : express . Response ) => Promise < void > {
724
746
return async ( req : Request , res : express . Response ) : Promise < void > => {
747
+ const abortController = new AbortController ( ) ;
748
+ let heartbeatInterval : NodeJS . Timeout | null = null ;
749
+
750
+ const cleanup = ( ) => {
751
+ if ( heartbeatInterval ) {
752
+ clearInterval ( heartbeatInterval ) ;
753
+ heartbeatInterval = null ;
754
+ }
755
+ req . removeAllListeners ( 'close' ) ;
756
+ } ;
757
+
758
+ req . on ( 'close' , ( ) => {
759
+ cleanup ( )
760
+ abortController . abort ( ) ;
761
+ } ) ;
762
+
725
763
try {
726
764
if ( ! isValidRequest ( req ) ) {
727
765
logger . error ( "Invalid request, unable to process." ) ;
@@ -791,24 +829,41 @@ function wrapOnCallHandler<Req = any, Res = any>(
791
829
...context ,
792
830
data,
793
831
} ;
794
- // TODO: set up optional heartbeat
795
832
const responseProxy : CallableProxyResponse = {
796
833
write ( chunk ) : boolean {
797
- if ( acceptsStreaming ) {
798
- const formattedData = encodeSSE ( { message : chunk } ) ;
799
- return res . write ( formattedData ) ;
800
- }
801
834
// if client doesn't accept sse-protocol, response.write() is no-op.
835
+ if ( ! acceptsStreaming ) {
836
+ return false
837
+ }
838
+ // if connection is already closed, response.write() is no-op.
839
+ if ( abortController . signal . aborted ) {
840
+ return false
841
+ }
842
+ const formattedData = encodeSSE ( { message : chunk } ) ;
843
+ return res . write ( formattedData ) ;
802
844
} ,
803
845
acceptsStreaming,
846
+ signal : abortController . signal
804
847
} ;
805
848
if ( acceptsStreaming ) {
806
849
// SSE always responds with 200
807
850
res . status ( 200 ) ;
851
+ const heartbeatSeconds = options . heartbeatSeconds ?? DEFAULT_HEARTBEAT_SECONDS ;
852
+ if ( heartbeatSeconds !== null && heartbeatSeconds > 0 ) {
853
+ heartbeatInterval = setInterval (
854
+ ( ) => res . write ( ": ping\n" ) ,
855
+ heartbeatSeconds * 1000
856
+ ) ;
857
+ }
808
858
}
809
859
// For some reason the type system isn't picking up that the handler
810
860
// is a one argument function.
811
861
result = await ( handler as any ) ( arg , responseProxy ) ;
862
+
863
+ if ( heartbeatInterval ) {
864
+ clearInterval ( heartbeatInterval ) ;
865
+ heartbeatInterval = null ;
866
+ }
812
867
}
813
868
814
869
// Encode the result as JSON to preserve types like Dates.
@@ -837,6 +892,8 @@ function wrapOnCallHandler<Req = any, Res = any>(
837
892
} else {
838
893
res . status ( status ) . send ( body ) ;
839
894
}
895
+ } finally {
896
+ cleanup ( )
840
897
}
841
898
} ;
842
899
}
0 commit comments