WebView2Feedback icon indicating copy to clipboard operation
WebView2Feedback copied to clipboard

Stream HTTP responses to WebView2

Open NickDarvey opened this issue 2 years ago β€’ 18 comments

I want to stream HTTP responses to WebView2. I don't think this is possible right now, so this is a feature request.

For example, I want to intercept a request and return server-sent events which the client can process per event.

For example, I want to intercept a request and return a chunked response which the client can process incrementally.

Repro

(Or how it could work)

Host C# code

async void WebResourceRequested(CoreWebView2 sender, CoreWebView2WebResourceRequestedEventArgs args)
{
  var stream = new InMemoryRandomAccessStream();
  var writer = new DataWriter(stream);

  var response = sender.Environment.CreateWebResourceResponse(
      Content: stream, // new AsyncEnumerableRandomAccessStream(results),
      StatusCode: 200,
      ReasonPhrase: "OK",
      Headers: string.Join('\n',
          $"Content-Type: text/event-stream",
          $"Cache-Control: no-cache",
          $"Connection: keep-alive"));

  args.Response = response;

  for (int i = 0; i < 4; i++)
  {
      writer.WriteString($"event: count\n");
      writer.WriteString($"data: {{ \"count\": {i} }}\n");
      writer.WriteString("\n");
      await writer.StoreAsync();
      await writer.FlushAsync();

      await Task.Delay(200);
  }

  // or maybe deferral should be completed earlier
  // but I can continue to write to the stream?
  deferral.Complete();
}

await view.EnsureCoreWebView2Async();
view.CoreWebView2.AddWebResourceRequestedFilter("my-example-url", CoreWebView2WebResourceContext.All);
view.CoreWebView2.WebResourceRequested += WebResourceRequested;

Client JavaScript code

fetch("my-example-url")
  .then((response) => response.body.pipeThrough(new TextDecoderStream()).getReader())
  .then(async (reader) => {
    console.log("Started");
    while (true) {
      const { value, done } = await reader.read();
      if (done) {
        console.log("Completed");
        break;
      }
      console.log("Received", value);
    }
    reader.releaseLock();
  })
  .catch(console.error);

Expected

I want the response to be streamed so the client can process each event as it's sent.

Started
Received event: count
data: { "count": 0 }

Received event: count
data: { "count": 1 }

Received event: count
data: { "count": 2 }

Received event: count
data: { "count": 3 }


Completed

For example, running the JavaScript client code with the url https://sse.dev/test will behave like this.

Actual

The response is buffered and becomes available to the client all-at-once.

Started
Received event: count
data: { "count": 0 }

event: count
data: { "count": 1 }

event: count
data: { "count": 2 }

event: count
data: { "count": 3 }


Completed

System info

  • Microsoft.WindowsAppSDK 1.2.221209.1
  • Microsoft.Windows.SDK.NET 10.00.19041.28
  • Microsoft.WinUI 3.00.0.2212

AB#47606624

NickDarvey avatar May 28 '23 10:05 NickDarvey

Hi @NickDarvey,

Thanks for your advice! To help better understand your need, could you describe the specific usage scenario in detail?

plantree avatar Jun 15 '23 02:06 plantree

@plantree, I'm building a hybrid app where I have some web components (JS) hosted in a native (WinUI3) application.

I want my web components to be able to use services offered by my application so that, for example:

  1. the web component can react to application lifecycle events raised in the native app

  2. the web component can display large text documents from a zip file that was uncompressed in the native app

Both examples would benefit from being able to intercept a HTTP request and stream responses back to the client.

For (1), the web component could subscribe to the lifecycle events with a fetch to http://0.0.0.0/app/lifecycle and the native app could publish each lifecycle event using server-sent events (or some binary type-length-value encoding), so the web component can process them as a sequence.

For (2), the web component could request the document with a fetch to http://0.0.0.0/packages/1234/docs/abcd and the native app could return unzip and return the document line-by-line, so the web component can display it progressively.

The alternative is to correlate successive web messages (CoreWebView2.PostWebMessageAsJson) back into a sequence with the downside of (a) needing to do the correlation and (b) needing to marshal everything to a string.

This functionality is available on other platforms. For example, on iOS and macOS with WKWebView:

After you load some portion of the resource data, call the didReceiveData: method to send it to WebKit. You may call that method multiple times to deliver data incrementally, or call it once with all of the data.

https://developer.apple.com/documentation/webkit/wkurlschemetask

NickDarvey avatar Jun 16 '23 06:06 NickDarvey

Hi @NickDarvey,

Thanks for your information and professional analysis!

If it's possible, could you provide a simple .sln project to help better service this feature. Thanks.

plantree avatar Jun 19 '23 03:06 plantree

@plantree, I think the code I provided in my original post is a simple example of how it might work.

NickDarvey avatar Jun 19 '23 06:06 NickDarvey

Is this item saying sse is not currently supported by webview2?

PylotLight avatar Nov 12 '23 01:11 PylotLight

Is this item saying sse is not currently supported by webview2?

@PylotLight, SSE is not supported if you’re intercepting requests with WebView2’s WebResourceRequested filters, but it works fine otherwise.

NickDarvey avatar Nov 12 '23 04:11 NickDarvey

Hi Nick - I will track this feature request in our internal team backlog. In the meantime can you help me understand if you currently have a workaround ( my assumption is no? )

victorhuangwq avatar Nov 13 '23 18:11 victorhuangwq

I wrapped fetch body's getReader to communicate with the app.

Javascript part:

(function () {
    'use strict';

    (function () {
        if (!window.onKAppEventStream) {
            const eventRegisters = {};

            const onKAppEventStream = (data) => {
                let { uuid, final, content } = data;
                if (!eventRegisters[uuid]) {
                    eventRegisters[uuid] = {
                        instance: null,
                        pending: [],
                    };
                }
                if (final) {
                    content = null;
                } else {
                    if (typeof content === 'string') {
                        content = new TextEncoder().encode(content);
                    }
                }
                eventRegisters[uuid].pending.push({ final, content });
                if (eventRegisters[uuid].instance) {
                    eventRegisters[uuid].instance.notifyMessage();
                }
            };

            Object.defineProperty(window, 'onKAppEventStream', {
                value: onKAppEventStream,
                configurable: true,
                writable: true,
                enumerable: false,
            });

            class KAppFetchEventStream {
                constructor(eventStreamId) {
                    this.eventStreamId = eventStreamId;
                    this.finished = false;
                    this.finishResolver = new Promise(resolve => this.runFinishResolve = resolve);
                }
                cancel() {
                    return this.finishResolver;
                }
                notifyMessage() {
                    if (this.messageNotifier) {
                        const noti = this.messageNotifier;
                        this.messageNotifier = null;
                        noti();
                    }
                }
                onFinish() {
                    if (!this.finished) {
                        this.finished = true;
                        this.runFinishResolve();
                        delete eventRegisters[this.eventStreamId];
                    }
                }
                triggerFinal() {
                    if (!this.finished) {
                        setTimeout(() => this.onFinish(), 100);
                    }
                }
                async read() {
                    if (!eventRegisters[this.eventStreamId]) {
                        eventRegisters[this.eventStreamId] = {
                            instance: this,
                            pending: [],
                        };
                    } else {
                        eventRegisters[this.eventStreamId].instance = this;
                    }
                    if (eventRegisters[this.eventStreamId].pending.length == 0) {
                        while (this.messageNotifier) {
                            await new Promise(resolve => setTimeout(resolve, 100));
                        }
                        await new Promise(resolve => this.messageNotifier = resolve);
                    }
                    if (eventRegisters[this.eventStreamId].pending.length > 0) {
                        const { final, content } = eventRegisters[this.eventStreamId].pending.shift();
                        if (final) this.triggerFinal();
                        return { done: final, value: content };
                    }
                }
                releaseLock() {}
            }

            ((fetch) => {
                window.fetch = async function (uri, options, ...args) {
                    let r = await fetch.call(this, uri, options, ...args);
                    let eventStreamId = r.headers.get('kapp-event-stream');
                    if (eventStreamId) {
                        r.body.getReader = () => {
                            return new KAppFetchEventStream(eventStreamId);
                        }
                    }
                    return r;
                };
            })(fetch);
        }
    })();
})();

C# part:

...
// in handleWebResourceRequested:, when we get an event-stream response, just finish the response and use simulateStreamResponse to send parts to the website
if (contentType != null && contentType.Split(';')[0] == "text/event-stream")
{
    var uuid = Guid.NewGuid().ToString();
    _ = simulateStreamResponse(uuid, responseStream);
    var emptyResponseStream = new InMemoryRandomAccessStream();
    return contentWebView.CoreWebView2.Environment.CreateWebResourceResponse(
        emptyResponseStream.AsStreamForRead(),
        (int)response.StatusCode,
        response.ReasonPhrase,
        build_reponse_header(response, new_length: 0, additional: new string[] { $"kapp-event-stream: {uuid}" })
    );
}
...
void sendStreamResponse(string uuid, bool final, string? content)
{
    BeginInvoke(async () =>
    {
        var finalJS = final ? "true" : "false";
        if (content == null)
        {
            await contentWebView.ExecuteScriptAsync($"(window.onKAppEventStream||console.log)({{uuid:'{uuid}',final:{finalJS},content:null}});");
        }
        else
        {
            var bytes = System.Text.Encoding.UTF8.GetBytes(content);
            var bytesString = "[" + String.Join(",", bytes) + "]";
            var eval = $"(window.onKAppEventStream||console.log)({{uuid:'{uuid}',final:{finalJS},content:Uint8Array.from({bytesString})}});";
            await contentWebView.ExecuteScriptAsync(eval);
        }
    });
}
async Task simulateStreamResponse(string uuid, Stream? stream)
{
    try
    {
        if (stream == null)
        {
            return;
        }
        var reader = new StreamReader(stream);
        var line = "";
        while (!reader.EndOfStream && (line = await reader.ReadLineAsync()) != null)
        {
            sendStreamResponse(uuid, false, line + "\n");
            await Task.Delay(10);
        }
    }
    finally
    {
        sendStreamResponse(uuid, true, null);
    }
}

vhqtvn avatar Nov 13 '23 18:11 vhqtvn

I tried to implement Response Streaming for Wails and for that purpose did a custom implementation of IStream on the Go side and gave it back as ICoreWebView2WebResourceResponse.Content. The problem is, that it seems like WebView2 only uses one background thread for reading all the response Streams. So If one of the IStream's Read method is blocking, the whole request processing also for other requests is blocked as well. For SSE it is not possible to have the whole content available when finishing the deferral as documented here.

Stream must have all the content data available by the time the WebResourceRequested event deferral of this response is completed. Stream should be agile or be created from a background thread to prevent performance impact to the UI thread.

So this would need something like ReadAsync support from IStreamAsync.

Furthermore we would also need a way to find out if the request has been stopped by WebView2. For example let's say one does an SSE streaming and WebView2 does a reload of the document. In that case we would need a way to get informed that the request is getting stopped and the stop the SSE streaming process on the host side.

stffabi avatar Nov 27 '23 07:11 stffabi

@victorhuangwq @yildirimcagri-msft Any suggestions based on the comment from staffabi above?

PylotLight avatar Jan 04 '24 13:01 PylotLight

Any updates? We are in a similar situation, we want to stream HTTP responses to the client, tipically for download large files. It seems the response becomes available all-at-once to the client js.

MarcoB89 avatar Mar 12 '24 20:03 MarcoB89

@vbryh-msft could you help us understand if this is something can already be done? And if this feature request is valid?

victorhuangwq avatar Mar 12 '24 20:03 victorhuangwq

skimmed through request and comments - does SharedBuffer API can be used there?

vbryh-msft avatar Mar 12 '24 21:03 vbryh-msft

SharedBuffer API does not help in this case, we would like to stream HTTP responses back. So the frontend code could use a simple fetch call or the EventSource API.

stffabi avatar May 14 '24 18:05 stffabi

+1 for this use case. I am looking at using something like this mostly for improved performance in getting data from the backend to the frontend, and while the SharedBuffer API could also provide this performance increase, it is a much lower-level API and requires lots of manual synchronization implementation which makes it an order of magnitude more complex.

samkearney avatar Jun 26 '24 17:06 samkearney

Any news on this team? This is quite a high priority for us as both Mac and Linux equivalents have it and the support on Windows is lagging. Appreciate your work! πŸ™

leaanthony avatar Nov 20 '24 08:11 leaanthony

Any news on this team? This is quite a high priority for us as both Mac and Linux equivalents have it and the support on Windows is lagging. Appreciate your work! πŸ™

leaanthony avatar Feb 10 '25 08:02 leaanthony

Any progress on this issue? We would also need the ability to stream responses as chunks on an intercepted request.

I think this would also solve another issue I created a while back which is https://github.com/MicrosoftEdge/WebView2Feedback/issues/4294. Despite of the IStream interface that is used for put_Content the data is not really streamed to WebView2. It looks like it's accumulated somewhere in between and then pushed to WebView2 in one large response. This can lead to heavy memory consumption in the process which hosts the WebView2.

Urmeli0815 avatar Mar 30 '25 17:03 Urmeli0815

Please team, there's been zero feedback on this for over a year. @champnic - sorry for the ping but is there any chance of some feedback on this? πŸ™

leaanthony avatar Apr 27 '25 04:04 leaanthony