MicrosoftEdge / WebView2Feedback

Feedback and discussions about Microsoft Edge WebView2
https://aka.ms/webview2
423 stars 51 forks source link

Stream HTTP responses to WebView2 #3519

Open NickDarvey opened 1 year ago

NickDarvey commented 1 year ago

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

AB#47606624

plantree commented 1 year ago

Hi @NickDarvey,

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

NickDarvey commented 1 year ago

@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

plantree commented 1 year ago

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.

NickDarvey commented 1 year ago

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

PylotLight commented 8 months ago

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

NickDarvey commented 8 months ago

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.

victorhuangwq commented 7 months ago

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? )

vhqtvn commented 7 months ago

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);
    }
}
stffabi commented 7 months ago

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.

PylotLight commented 6 months ago

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

MarcoB89 commented 3 months ago

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.

victorhuangwq commented 3 months ago

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

vbryh-msft commented 3 months ago

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

stffabi commented 1 month ago

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.

samkearney commented 1 week ago

+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.