webview_deno
webview_deno copied to clipboard
Promise returned by webview.bind callback never resolves
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));
}
});
}
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.
https://github.com/denoland/deno/pull/15116
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 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));
});
}