webview_deno icon indicating copy to clipboard operation
webview_deno copied to clipboard

Promise returned by webview.bind callback never resolves

Open peetklecha opened this issue 1 year ago • 4 comments

My issue is that when I return a promise from the callback passed into webview.bind, that promise never resolves. Unless I'm missing something it seems like something is blocking the event loop.

deno 1.23.2 (release, x86_64-apple-darwin)
v8 10.4.132.8
typescript 4.7.2

To reproduce

import { Webview } from "https://deno.land/x/[email protected]/mod.ts";

const webview = new Webview();
webview.navigate(`data:text/html,${
  encodeURIComponent(`
  <html>
    <script>
      log("hello world").then(log);
    </script>
    <body>
      <h1>hello</h1>
    </body>
  </html>
`)}`);
webview.bind("log", /* async */ (...args: string[]) => {
  // await Promise.resolve();
  console.log(...args);
  return args;
});
webview.run();
deno run -Ar --unstable test.ts

This should produce the following logs:

hello world
[ "hello world" ]

Uncommenting the async flag on the callback, the output becomes:

hello world

Finally uncommenting the await Promise.resolve() results in no logs produced at all.

These additions should have no result on the log output. At first I thought that possibly async callbacks were not allowed, but the source for webview.bind seems to explicitly expect the callback may return a promise:

  bind(
    name: string,
    // deno-lint-ignore no-explicit-any
    callback: (...args: any) => any,
  ) {
    this.bindRaw(name, (seq, req) => {
      const args = JSON.parse(req);
      let result;
      let success: boolean;
      try {
        result = callback(...args);
        success = true;
      } catch (err) {
        result = err;
        success = false;
      }
      if (result instanceof Promise) {
        result.then((result) =>
          this.return(seq, success ? 0 : 1, JSON.stringify(result))
        );
      } else {
        this.return(seq, success ? 0 : 1, JSON.stringify(result));
      }
    });
  }

peetklecha avatar Jul 05 '22 01:07 peetklecha

Yep, this is an issue (or rather, I think, intended behaviour) with the event loop as webview.run has taken ahold of it only allowing sync callbacks to be run as async requires the v8 event loop to be the one in control, which it isn't. This might be solveable with threading using workers but it's a quite cumbersome fix as you would have to do message passing and "dispatching" using the yet unimplemented webview.dispatch method which allows methods to be dispatched safely on the webview thread.

eliassjogreen avatar Jul 05 '22 07:07 eliassjogreen

https://github.com/denoland/deno/pull/15116

eliassjogreen avatar Jul 07 '22 17:07 eliassjogreen

I tried to do this in a somewhat cumbersome way, for fetches that ignore CORS:

deno:

let fetches = 0
webview.bind('_fetchJSON', (url, options) => {
  const f = fetches++
  console.log('started fetch', f)
  fetch(url, options).then(r => r.json()).then(results => {
    console.log('finished fetch', f)
    webview.eval(`
      window.fetches[${f}] = ${JSON.stringify(results)})
    `)
  })
  return fetches
})

webview:

window.fetches = {}
async function fetchJSON (url, options) {
  const id = await _fetchJSON(url, options)
  console.log('STARTING FETCH', id)
  return new Promise((resolve, reject) => {
    const i = setInterval(() => {
      if (typeof window.fetches[id] !== 'undefined') {
        console.log('FETCH COMPLETE', id)
        clearInterval(i)
        resolve(globalThis.fetches)
      }
    }, 10)
  })
}

but the bind never seems to log finished fetch Is there another way I should do this?

konsumer avatar Nov 12 '22 03:11 konsumer

@konsumer Here's a working solution for a fetch proxy. Please include an MIT license declaration and my name as author with this code.

./types.ts

export type WebviewResponseJSON = {
	headers: Record<string, string | string[]>;
	ok: boolean;
	redirected: boolean;
	status: number;
	statusText: string;
	type: ResponseType;
	url: string;
	data: number[];
};

./fetch_worker.ts

import type { WebviewResponseJSON } from './types.ts';

async function responseToJSON(response: Response): Promise<WebviewResponseJSON> {
	const arrayBuffer = await response.arrayBuffer();
	const headers: Record<string, string | string[]> = {};
	response.headers.forEach((value, key) => {
		if (headers[key]) {
			if (Array.isArray(headers[key])) {
				(headers[key] as string[]).push(value);
			} else {
				headers[key] = [headers[key] as string, value];
			}
		} else {
			headers[key] = value;
		}
	});
	return {
		headers,
		ok: response.ok,
		redirected: response.redirected,
		status: response.status,
		statusText: response.statusText,
		type: response.type,
		url: response.url,
		data: Array.from(new Uint8Array(arrayBuffer)),
	};
}

const { args } = JSON.parse(Deno.args[0]) as { args: Parameters<typeof fetch> };
const response = await responseToJSON(await fetch(...args));
console.log(JSON.stringify(response));

./fetch_proxy.ts

import type { Webview } from '../deps.ts';
import type { WebviewResponseJSON } from './types.ts';

/** This class will be serialized for use by the Webview JavaScript runtime. */
class WebviewResponse implements Response {
	#decoder: TextDecoder;
	#primitive: WebviewResponseJSON;
	#primitiveBuf: ArrayBuffer;
	headers: Headers;
	ok: boolean;
	status: number;
	statusText: string;
	type: ResponseType;
	url: string;
	redirected: boolean;
	body: ReadableStream<Uint8Array> | null;
	bodyUsed: boolean;

	constructor(response: WebviewResponseJSON) {
		const { data: dataArray } = response;
		const data = new Uint8Array(dataArray);
		const { length } = data;
		const limit = 16;
		let start = 0;
		let end = limit;
		this.#decoder = new TextDecoder();
		this.#primitive = response;
		this.#primitiveBuf = data.buffer;
		this.headers = new Headers();
		this.ok = response.ok;
		this.status = response.status;
		this.statusText = response.statusText;
		this.type = response.type;
		this.url = response.url;
		this.redirected = response.redirected;
		this.bodyUsed = false;
		for (const [key, value] of Object.entries(response.headers)) {
			if (Array.isArray(value)) {
				for (const item of value) {
					this.headers.append(key, item);
				}
			} else {
				this.headers.append(key, value);
			}
		}
		this.body = length === 0 ? null : new ReadableStream({
			pull: (controller) => {
				if (start < length) {
					controller.enqueue(data.slice(start, end));
					start += limit;
					end += limit;
				} else {
					controller.close();
					this.bodyUsed = true;
				}
			},
		});
	}

	arrayBuffer(): Promise<ArrayBuffer> {
		this.bodyUsed = true;
		return Promise.resolve(this.#primitiveBuf);
	}

	blob(): Promise<Blob> {
		this.bodyUsed = true;
		return Promise.resolve(new Blob([new Uint8Array(this.#primitiveBuf)]));
	}

	formData(): Promise<FormData> {
		this.bodyUsed = true;
		return new Response(this.#primitiveBuf, {
			headers: this.headers,
			status: this.status,
			statusText: this.statusText,
		}).formData();
	}

	async json(): Promise<unknown> {
		return JSON.parse(await this.text());
	}

	async text(): Promise<string> {
		this.bodyUsed = true;
		return await this.#decoder.decode(this.#primitiveBuf);
	}

	clone(): WebviewResponse {
		return new WebviewResponse(this.#primitive);
	}
}

/** This function will be serialized for use by the Webview JavaScript runtime. */
async function fetchProxy(...args: Parameters<typeof fetch>) {
	const response = await __fetchProxy({ args });
	return new WebviewResponse(response!);
}

type FetchProxyInternal = (input: { args: Parameters<typeof fetch> }) => WebviewResponseJSON;
/** This exists as a no-op to allow us to define and typecheck the function before serializing. */
// deno-lint-ignore no-explicit-any
const __fetchProxy: FetchProxyInternal = (..._args): any => {};
const decoder = new TextDecoder();
const worker_path = new URL('./fetch_worker.ts', import.meta.url).href;

/** Add a fetch proxy to a webview instance. */
export function fetch_proxy(webview: Webview): void {
	webview.init(`
		window['WebviewResponse'] = ${WebviewResponse.toString()};
		window['fetchProxy'] = ${fetchProxy.toString()};
	`);
	webview.bind('__fetchProxy', (input: { args: Parameters<typeof fetch> }) => {
		const args = ['run', '-A', worker_path, JSON.stringify({ ...input })];
		const command = new Deno.Command('deno', { args });
		const { stdout } = command.outputSync();
		return JSON.parse(decoder.decode(stdout));
	});
}

aaronhuggins avatar Apr 27 '23 15:04 aaronhuggins