Recent

Author Topic: FpHttpServer and "transfer-encoding: chunked"  (Read 1419 times)

piola

  • Full Member
  • ***
  • Posts: 145
  • Lazarus 2.2, 64bit on Windows 8.1 x64
FpHttpServer and "transfer-encoding: chunked"
« on: June 17, 2024, 09:54:37 pm »
Hello,

I'm using the FpHttpServer component which is working very well. Now I want to sent chunked responses and I cannot figure out how to do that. The obvious approach of setting the content-length to zero, using a memory stream and calling `WriteBuffer` (or something similar) whenever there are new data to send doesn't work. Either it waits until all data is collected and sends it all at once, or I get a "response already sent" exception when trying to call `AResponse.SendResponse` a second time.

So what it is the correct way of sending chunked HTTP/1.1 responses?

Remy Lebeau

  • Hero Member
  • *****
  • Posts: 1402
    • Lebeau Software
Re: FpHttpServer and "transfer-encoding: chunked"
« Reply #1 on: June 22, 2024, 12:12:24 am »
TFPHttpServer does not natively support sending chunked responses (or reading chunked requests), so you will need to handle this manually.

Ideally, your request handler would be able to:

  • add a 'Transfer-Encoding: chunked' header and omit a 'Content-Length' header (not just set it to zero),
  • then call TResponse.SendHeaders(),
  • then set TResponse.ContentSent=True and write your chunks directly to the TResponse.Connection.Socket stream.

Unfortunately, TResponse.ContentSent is read-only, and its backing data member is private.  The only way to set it to True (thus preventing TFPHttpServer from trying to send a response body after your handler exits) is to call TResponse.SendContent() directly.

You might be able to leave the TResponse.ContentStream and TResponse.Contents properties completely empty, then have your request handler call TResponse.SendResponse() (don't forget to add the 'chunked' header), and then write the chunks to the Connection.Socket stream.  That may work.

But, if it doesn't, then the only other option I can find is to:

  • Derive a new class from TFPHTTPConnectionResponse and override its virtual DoSendContent() method to write chucks to the Connection.Socket stream as needed, instead of simply writing the ContentStream or Content property as-is.
  • Derive a new class from TFPHttpServer and override its virtual CreateResponse() method to return a new instance of your custom Response class above.
« Last Edit: June 23, 2024, 09:14:05 pm by Remy Lebeau »
Remy Lebeau
Lebeau Software - Owner, Developer
Internet Direct (Indy) - Admin, Developer (Support forum)

piola

  • Full Member
  • ***
  • Posts: 145
  • Lazarus 2.2, 64bit on Windows 8.1 x64
Re: FpHttpServer and "transfer-encoding: chunked"
« Reply #2 on: June 23, 2024, 09:53:34 am »
Thank you for your comprehensive answer. In the meantime I have studied the sources for TFpHttpServer and its parent classes and it looks as if I may choose the 2nd option. I'll post my results when I'm successful.

piola

  • Full Member
  • ***
  • Posts: 145
  • Lazarus 2.2, 64bit on Windows 8.1 x64
Re: FpHttpServer and "transfer-encoding: chunked"
« Reply #3 on: June 26, 2024, 12:35:26 pm »
Ok, for others with the same problem: I have chosen the 2nd option because it looks cleaner to me, and it seems to work. What I have done is:

Code: Pascal  [Select][+][-]
  1. uses HttpProtocol, fpHttpServer, HttpDefs;
  2.  
  3. type TChunkableResponse = class(TFpHttpConnectionResponse)
  4.   public function SendChunk: Boolean;
  5. end;
  6.  
  7. type TChunkableHttpServer = class(TFpHttpServer)
  8.   protected function CreateResponse (ARequest: TFpHttpConnectionRequest): TRestResponse; override;
  9. end;
  10.  
  11. function TChunkableHttpServer.CreateResponse (ARequest: TFpHttpConnectionRequest): TRestResponse;
  12. begin
  13.   Result := TChunkableResponse.Create (ARequest);
  14. end;
  15.  
  16. function TChunkableResponse.SendChunk: Boolean;
  17. var len: String;
  18. begin
  19.   // see comments below
  20.   if not Assigned (ContentStream) then Exit (false);
  21.   if Connection.Socket.Closed or Connection.Socket.PeerClosed then Exit (false);
  22.  
  23.   // there should not be a "content-length" header
  24.   SetHeader (hhContentLength, '');
  25.  
  26.   if not HeadersSent then SendHeaders;
  27.  
  28.   // report success if there are data to send
  29.   Result := (ContentStream.Size > 0);
  30.  
  31.   try
  32.     // send chunk length
  33.     len := Format('%x'#13#10, [ContentStream.Size]);
  34.     Connection.Socket.WriteBuffer (len[1], Length(len));
  35.  
  36.     // send chunk content
  37.     Connection.Socket.CopyFrom (ContentStream, 0);
  38.  
  39.     // send "end of chunk"
  40.     Connection.Socket.WriteByte ($0D);
  41.     Connection.Socket.WriteByte ($0A);
  42.   except
  43.     Result := false;
  44.   end;
  45.  
  46.   // clear response
  47.   ContentStream.Size := 0;
  48.   ContentStream.Position := 0;
  49. end;
  50.  

Two comments:
  • TChunkableResponse.SendChunk returns true if a non-empty chunk has been sent, false otherwise. It uses the underlying ContentStream, so to send a chunk one uses ContentStream.Write... first, and SendChunk afterwards.
  • Originally, I wanted to check whether the connection to the client is still alive. That's where the checks for Socket.Closed and Socket.PeerClosed come from. However, they seem to be always true. I ended up in adding the try...except block which is activated when the connection has been closed on the client side.

Remy Lebeau

  • Hero Member
  • *****
  • Posts: 1402
    • Lebeau Software
Re: FpHttpServer and "transfer-encoding: chunked"
« Reply #4 on: June 26, 2024, 07:18:56 pm »
Code: Pascal  [Select][+][-]
  1.   // there should not be a "content-length" header
  2.   SetHeader (hhContentLength, '');
  3.  
  4.   if not HeadersSent then SendHeaders;

I would suggest exiting early if the headers have already been sent, as it would be too late by then to modify them.  Also, since you are modifying the headers anyway, you should just add the required 'Transfer-Encoding: chunked' header before then sending the headers and chunks.

More importantly, your code is sending the entire ContentStream as a single chunk, which defeats the purpose of chunking. I would suggest reading from the ContentStream in a loop and sending a chunk on each iteration.  That way, you can then use a ContentStream which doesn't need to provide all of its data up front.

Also, you are not sending the terminating 0-length chunk to signal the end-of-data.

Try something more like this:

Code: Pascal  [Select][+][-]
  1. type
  2.   TChunkableResponse = class(TFpHttpConnectionResponse)
  3.   public
  4.     procedure SendChunked;
  5.   end;
  6.      
  7. ...
  8.      
  9. procedure TChunkableResponse.SendChunked;
  10. var
  11.   len: AnsiString;
  12.   buf: array[0..1023] of byte;
  13.   numRead: Integer;
  14.   streamToSend: TStream;
  15.   freeStream: Boolean;
  16. begin
  17.   if HeadersSent then
  18.   begin
  19.     inherited SendContent;
  20.     Exit;
  21.   end;
  22.  
  23.   streamToSend := ContentStream;
  24.   try
  25.     freeStream := not Assigned(streamToSend);
  26.     if freeStream then
  27.     begin
  28.       streamToSend := TMemoryStream.Create;
  29.       Contents.SaveToStream(streamToSend);
  30.       streamToSend.Position := 0;
  31.     end;
  32.  
  33.     // there should not be a "content-length" header
  34.     SetHeader (hhContentLength, '');
  35.      
  36.     // there should be a "transfer-encoding" header
  37.     SetHeader (hhTransferEncoding, 'chunked');
  38.  
  39.     SendHeaders;
  40.      
  41.     repeat
  42.       numRead := streamToSend.Read(buf[0], Length(buf));
  43.       if numRead <= 0 then Break;
  44.  
  45.       // send chunk length
  46.       len := Format('%x'#13#10, [numRead]);
  47.       Connection.Socket.WriteBuffer (len[1], Length(len));
  48.      
  49.       // send chunk content
  50.       Connection.Socket.WriteBuffer (buf[0], numRead);
  51.      
  52.       // send "end of chunk"
  53.       Connection.Socket.WriteByte ($0D);
  54.       Connection.Socket.WriteByte ($0A);
  55.     until False;
  56.  
  57.     // send last chunk
  58.     len := '0'#13#10;
  59.     Connection.Socket.WriteBuffer (len[1], Length(len));
  60.  
  61.   finally
  62.     if freeStream then
  63.       streamToSend.Free;
  64.   end;
  65. end;
  66.  

Now, that being side, the whole purpose of my suggestion of making a TFpHttpConnectionResponse descendant was to not introduce a completely new send method, but to override the virtual DoSendContent() method so the existing SendContent() method would "just work".

For example:

Code: Pascal  [Select][+][-]
  1. type
  2.   TChunkableResponse = class(TFpHttpConnectionResponse)
  3.   private
  4.     FSendContentChunked: Boolean;
  5.   protected
  6.     procedure DoSendHeaders(Headers : TStrings); override;
  7.     procedure DoSendContent; override;
  8.   end;
  9.      
  10. ...
  11.      
  12. procedure TChunkableResponse.DoSendHeaders(Headers : TStrings);
  13. var
  14.   I: Integer;
  15.   Hdr: string;
  16.  
  17.   function IsSendingChunked: Boolean;
  18.   begin
  19.     Result := False;
  20.     for I := 0 to Headers.Count-1 do
  21.     begin
  22.       Hdr := Lowercase(Headers[I]);
  23.       if StartsStr('transfer-encoding:', Hdr) and (Pos('chunked', Hdr, 19) > 0) then
  24.       begin
  25.         Result := True;
  26.         Break;
  27.       end;
  28.     end;
  29.   end;
  30.  
  31.   procedure RemoveContentLength;
  32.   begin
  33.     for I := 0 to Headers.Count-1 do
  34.     begin
  35.       Hdr := Headers[I];
  36.       if StartsText('Content-Length:', Hdr) then
  37.       begin
  38.         Headers.Delete(I);
  39.         Break;
  40.       end;
  41.     end;
  42.   end;
  43.  
  44. begin
  45.   FSendContentChunked := IsSendingChunked;
  46.   if FSendContentChunked then RemoveContentLength;
  47.   inherited DoSendHeaders(Headers);
  48. end;
  49.  
  50. procedure TChunkableResponse.DoSendContent;
  51. var
  52.   len: AnsiString;
  53.   buf: array[0..1023] of byte;
  54.   numRead: Integer;
  55.   streamToSend: TStream;
  56.   freeStream: Boolean;
  57. begin
  58.   if not FSendContentChunked then
  59.   begin
  60.     inherited DoSendContent;
  61.     Exit;
  62.   end;
  63.  
  64.   streamToSend := ContentStream;
  65.   try
  66.     freeStream := not Assigned(streamToSend);
  67.     if freeStream then
  68.     begin
  69.       streamToSend := TMemoryStream.Create;
  70.       Contents.SaveToStream(streamToSend);
  71.       streamToSend.Position := 0;
  72.     end;
  73.  
  74.     repeat
  75.       numRead := streamToSend.Read(buf[0], Length(buf));
  76.       if numRead <= 0 then Break;
  77.  
  78.       // send chunk length
  79.       len := Format('%x'#13#10, [numRead]);
  80.       Connection.Socket.WriteBuffer (len[1], Length(len));
  81.      
  82.       // send chunk content
  83.       Connection.Socket.WriteBuffer (buf[0], numRead);
  84.      
  85.       // send "end of chunk"
  86.       Connection.Socket.WriteByte ($0D);
  87.       Connection.Socket.WriteByte ($0A);
  88.     until False;
  89.  
  90.     // send last chunk
  91.     len := '0'#13#10;
  92.     Connection.Socket.WriteBuffer (len[1], Length(len));
  93.  
  94.   finally
  95.     if freeStream then
  96.       streamToSend.Free;
  97.   end;
  98. end;

This way, all you have to do is add a 'Transfer-Encoding: chunked' header to the Response and the server's default logic should chunk it automatically.
Remy Lebeau
Lebeau Software - Owner, Developer
Internet Direct (Indy) - Admin, Developer (Support forum)

 

TinyPortal © 2005-2018