Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Promise returned by webview.bind callback never resolves #131

Open
peetklecha opened this issue Jul 5, 2022 · 4 comments
Open

Promise returned by webview.bind callback never resolves #131

peetklecha opened this issue Jul 5, 2022 · 4 comments
Labels
bug Something isn't working

Comments

@peetklecha
Copy link

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));
      }
    });
  }
@eliassjogreen
Copy link
Member

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 eliassjogreen added the bug Something isn't working label Jul 5, 2022
@eliassjogreen
Copy link
Member

denoland/deno#15116

@konsumer
Copy link

konsumer commented Nov 12, 2022

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?

@aaronhuggins
Copy link
Contributor

aaronhuggins commented Apr 27, 2023

@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));
	});
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Development

No branches or pull requests

4 participants