mirror of
https://github.com/gradio-app/gradio.git
synced 2025-02-23 11:39:17 +08:00
Lite v4 (#6398)
* Fix vite.config.js detecting the development mode * Fix the imports of @gradio/theme in js/app/src/lite/index.ts * [WIP] Install Pydantic V1 and mock the RootModel class * Remove Wasm WebSocket implementations * Move ASGI-HTTP conversion logic from the worker to the worker-proxy so we have fine controls on the ASGI connection at the worker-proxy level for the HTTP stream connection impl in the future * Fix asgi-types.ts * Create `WasmWorkerEventSource` and inject the `EventSource` creation in @gradio/client * Mock Pydantic V2's BaseModel * Fix Pydantic V1 installation * Make <ImageUploader /> and <ImagePreview /> Wasm-compatible * Create `getHeaderValue()` * Create `<DownloadLink />` for Wasm-compatible download and fix `<ImagePreview />` to use it * Make `gr.Video()` Wasm-compatible avoiding unnecessary execution of ffprobe * Move `<DownloadLink />` to @gradio/wasm and use it in `<VideoPreview />` too * Fix `<DownloadLink />` making `href` optional and adding `rel="noopener noreferrer"` * Make the download button of `<StaticAudio>` and `<Code />` Wasm-compatible * Make the download button of `<FilePreview />` Wasm-compatible * Improve the RootModel mock class for `.model_dump()` and `.model_json_schame()` to work * Make `<UploadProgress />` Wasm-compatible * Fix `WorkerProxy.httpRequest()` to use `decodeURIComponent()` to process `path` and `query_string` * Fix `<InteractiveAudio />` to make its upload feature Wasm-compatible * [WIP] Revert "Make `<UploadProgress />` Wasm-compatible" This reverts commit f96b4b7d5e92bb488cfe1939d25063366f714178. * Fix Image styles * Fix `<AudioPlayer />`'s `create_waveform()` to be Wasm-compatible * add changeset * formatting * Fix js/image/shared/Image.svelte to render <img> immediately * Fix js/image/shared/Image.svelte to avoid race condition * Fix js/image/shared/Image.svelte * Fix js/image/shared/Image.svelte * Fix js/image/shared/Image.svelte removing unnecessary styles * Fix js/video/shared/Video.svelte to use the passed immediately without waiting for the async resolution --------- Co-authored-by: gradio-pr-bot <gradio-pr-bot@users.noreply.github.com> Co-authored-by: aliabd <ali.si3luwa@gmail.com> Co-authored-by: Abubakar Abid <abubakar@huggingface.co>
This commit is contained in:
parent
9a6ff704cd
commit
67ddd40b4b
13
.changeset/shiny-news-float.md
Normal file
13
.changeset/shiny-news-float.md
Normal file
@ -0,0 +1,13 @@
|
||||
---
|
||||
"@gradio/app": minor
|
||||
"@gradio/audio": minor
|
||||
"@gradio/client": minor
|
||||
"@gradio/code": minor
|
||||
"@gradio/file": minor
|
||||
"@gradio/image": minor
|
||||
"@gradio/video": minor
|
||||
"@gradio/wasm": minor
|
||||
"gradio": minor
|
||||
---
|
||||
|
||||
feat:Lite v4
|
@ -178,7 +178,7 @@ interface Client {
|
||||
|
||||
export function api_factory(
|
||||
fetch_implementation: typeof fetch,
|
||||
WebSocket_factory: (url: URL) => WebSocket
|
||||
EventSource_factory: (url: URL) => EventSource
|
||||
): Client {
|
||||
return { post_data, upload_files, client, handle_blob };
|
||||
|
||||
@ -546,7 +546,7 @@ export function api_factory(
|
||||
url.searchParams.set("__sign", jwt);
|
||||
}
|
||||
|
||||
websocket = WebSocket_factory(url);
|
||||
websocket = new WebSocket(url);
|
||||
|
||||
websocket.onclose = (evt) => {
|
||||
if (!evt.wasClean) {
|
||||
@ -667,7 +667,7 @@ export function api_factory(
|
||||
)}/queue/join?${url_params ? url_params + "&" : ""}${params}`
|
||||
);
|
||||
|
||||
eventSource = new EventSource(url);
|
||||
eventSource = EventSource_factory(url);
|
||||
|
||||
eventSource.onmessage = async function (event) {
|
||||
const _data = JSON.parse(event.data);
|
||||
@ -1007,7 +1007,7 @@ export function api_factory(
|
||||
|
||||
export const { post_data, upload_files, client, handle_blob } = api_factory(
|
||||
fetch,
|
||||
(...args) => new WebSocket(...args)
|
||||
(...args) => new EventSource(...args)
|
||||
);
|
||||
|
||||
function transform_output(
|
||||
|
@ -165,16 +165,20 @@ class Video(Component):
|
||||
uploaded_format = file_name.suffix.replace(".", "")
|
||||
needs_formatting = self.format is not None and uploaded_format != self.format
|
||||
flip = self.sources == ["webcam"] and self.mirror_webcam
|
||||
duration = processing_utils.get_video_length(file_name)
|
||||
|
||||
if self.min_length is not None and duration < self.min_length:
|
||||
raise gr.Error(
|
||||
f"Video is too short, and must be at least {self.min_length} seconds"
|
||||
)
|
||||
if self.max_length is not None and duration > self.max_length:
|
||||
raise gr.Error(
|
||||
f"Video is too long, and must be at most {self.max_length} seconds"
|
||||
)
|
||||
if self.min_length is not None or self.max_length is not None:
|
||||
# With this if-clause, avoid unnecessary execution of `processing_utils.get_video_length`.
|
||||
# This is necessary for the Wasm-mode, because it uses ffprobe, which is not available in the browser.
|
||||
duration = processing_utils.get_video_length(file_name)
|
||||
if self.min_length is not None and duration < self.min_length:
|
||||
raise gr.Error(
|
||||
f"Video is too short, and must be at least {self.min_length} seconds"
|
||||
)
|
||||
if self.max_length is not None and duration > self.max_length:
|
||||
raise gr.Error(
|
||||
f"Video is too long, and must be at most {self.max_length} seconds"
|
||||
)
|
||||
|
||||
if needs_formatting or flip:
|
||||
format = f".{self.format if needs_formatting else uploaded_format}"
|
||||
output_options = ["-vf", "hflip", "-c:a", "copy"] if flip else []
|
||||
|
@ -11,9 +11,56 @@ from typing import Any, List, Optional, Union
|
||||
|
||||
from fastapi import Request
|
||||
from gradio_client.utils import traverse
|
||||
from pydantic import BaseModel, RootModel, ValidationError
|
||||
from typing_extensions import Literal
|
||||
|
||||
from . import wasm_utils
|
||||
|
||||
if not wasm_utils.IS_WASM:
|
||||
from pydantic import BaseModel, RootModel, ValidationError # type: ignore
|
||||
else:
|
||||
# XXX: Currently Pyodide V2 is not available on Pyodide,
|
||||
# so we install V1 for the Wasm version.
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from pydantic import BaseModel as BaseModelV1
|
||||
from pydantic import ValidationError, schema_of
|
||||
|
||||
# Map V2 method calls to V1 implementations.
|
||||
# Ref: https://docs.pydantic.dev/latest/migration/#changes-to-pydanticbasemodel
|
||||
class BaseModel(BaseModelV1):
|
||||
pass
|
||||
|
||||
BaseModel.model_dump = BaseModel.dict # type: ignore
|
||||
BaseModel.model_json_schema = BaseModel.schema # type: ignore
|
||||
|
||||
# RootModel is not available in V1, so we create a dummy class.
|
||||
PydanticUndefined = object()
|
||||
RootModelRootType = TypeVar("RootModelRootType")
|
||||
|
||||
class RootModel(BaseModel, Generic[RootModelRootType]):
|
||||
root: RootModelRootType
|
||||
|
||||
def __init__(self, root: RootModelRootType = PydanticUndefined, **data):
|
||||
if data:
|
||||
if root is not PydanticUndefined:
|
||||
raise ValueError(
|
||||
'"RootModel.__init__" accepts either a single positional argument or arbitrary keyword arguments'
|
||||
)
|
||||
root = data # type: ignore
|
||||
# XXX: No runtime validation is executed.
|
||||
super().__init__(root=root) # type: ignore
|
||||
|
||||
def dict(self, **kwargs):
|
||||
return super().dict(**kwargs)["root"]
|
||||
|
||||
@classmethod
|
||||
def schema(cls, **kwargs):
|
||||
# XXX: kwargs are ignored.
|
||||
return schema_of(cls.__fields__["root"].type_) # type: ignore
|
||||
|
||||
RootModel.model_dump = RootModel.dict # type: ignore
|
||||
RootModel.model_json_schema = RootModel.schema # type: ignore
|
||||
|
||||
|
||||
class PredictBody(BaseModel):
|
||||
class Config:
|
||||
|
@ -712,6 +712,10 @@ def convert_video_to_playable_mp4(video_path: str) -> str:
|
||||
|
||||
|
||||
def get_video_length(video_path: str | Path):
|
||||
if wasm_utils.IS_WASM:
|
||||
raise wasm_utils.WasmUnsupportedError(
|
||||
"Video duration is not supported in the Wasm mode."
|
||||
)
|
||||
duration = subprocess.check_output(
|
||||
[
|
||||
"ffprobe",
|
||||
|
@ -1,9 +1,12 @@
|
||||
import "@gradio/theme";
|
||||
import "@gradio/theme/src/reset.css";
|
||||
import "@gradio/theme/src/global.css";
|
||||
import "@gradio/theme/src/pollen.css";
|
||||
import "@gradio/theme/src/typography.css";
|
||||
import type { SvelteComponent } from "svelte";
|
||||
import { WorkerProxy, type WorkerProxyOptions } from "@gradio/wasm";
|
||||
import { api_factory } from "@gradio/client";
|
||||
import { wasm_proxied_fetch } from "./fetch";
|
||||
import { wasm_proxied_WebSocket_factory } from "./websocket";
|
||||
import { wasm_proxied_EventSource_factory } from "./sse";
|
||||
import { wasm_proxied_mount_css, mount_prebuilt_css } from "./css";
|
||||
import type { mount_css } from "../css";
|
||||
import Index from "../Index.svelte";
|
||||
@ -101,12 +104,12 @@ export function create(options: Options): GradioAppController {
|
||||
const overridden_fetch: typeof fetch = (input, init?) => {
|
||||
return wasm_proxied_fetch(worker_proxy, input, init);
|
||||
};
|
||||
const WebSocket_factory = (url: URL): WebSocket => {
|
||||
return wasm_proxied_WebSocket_factory(worker_proxy, url);
|
||||
const EventSource_factory = (url: URL): EventSource => {
|
||||
return wasm_proxied_EventSource_factory(worker_proxy, url);
|
||||
};
|
||||
const { client, upload_files } = api_factory(
|
||||
overridden_fetch,
|
||||
WebSocket_factory
|
||||
EventSource_factory
|
||||
);
|
||||
const overridden_mount_css: typeof mount_css = async (url, target) => {
|
||||
return wasm_proxied_mount_css(worker_proxy, url, target);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import type { WorkerProxy } from "@gradio/wasm";
|
||||
import { type WorkerProxy, WasmWorkerEventSource } from "@gradio/wasm";
|
||||
import { is_self_host } from "@gradio/wasm/network";
|
||||
|
||||
/**
|
||||
@ -6,14 +6,14 @@ import { is_self_host } from "@gradio/wasm/network";
|
||||
* which also falls back to the original WebSocket() for external resource requests.
|
||||
*/
|
||||
|
||||
export function wasm_proxied_WebSocket_factory(
|
||||
export function wasm_proxied_EventSource_factory(
|
||||
worker_proxy: WorkerProxy,
|
||||
url: URL
|
||||
): WebSocket {
|
||||
): EventSource {
|
||||
if (!is_self_host(url)) {
|
||||
console.debug("Fallback to original WebSocket");
|
||||
return new WebSocket(url);
|
||||
return new EventSource(url);
|
||||
}
|
||||
|
||||
return worker_proxy.openWebSocket(url.pathname) as unknown as WebSocket;
|
||||
return new WasmWorkerEventSource(worker_proxy, url) as unknown as EventSource;
|
||||
}
|
@ -47,6 +47,7 @@ export default defineConfig(({ mode }) => {
|
||||
"dev:custom": "../../gradio/templates/frontend"
|
||||
};
|
||||
const production = mode === "production" || mode === "production:lite";
|
||||
const development = mode === "development" || mode === "development:lite";
|
||||
const is_lite = mode.endsWith(":lite");
|
||||
|
||||
return {
|
||||
@ -131,7 +132,7 @@ export default defineConfig(({ mode }) => {
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
resolve_svelte(mode === "development"),
|
||||
resolve_svelte(development && !is_lite),
|
||||
|
||||
svelte({
|
||||
inspector: true,
|
||||
@ -150,7 +151,12 @@ export default defineConfig(({ mode }) => {
|
||||
}
|
||||
})
|
||||
}),
|
||||
generate_dev_entry({ enable: mode !== "development" && mode !== "test" }),
|
||||
generate_dev_entry({
|
||||
enable:
|
||||
!development &&
|
||||
!is_lite && // At the moment of https://github.com/gradio-app/gradio/pull/6398, I skipped to make Gradio-lite work custom component. Will do it, and remove this condition.
|
||||
mode !== "test"
|
||||
}),
|
||||
inject_ejs(),
|
||||
generate_cdn_entry({ version: GRADIO_VERSION, cdn_base: CDN_BASE }),
|
||||
handle_ce_css(),
|
||||
|
@ -1,7 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, createEventDispatcher } from "svelte";
|
||||
import { getContext, onDestroy, createEventDispatcher } from "svelte";
|
||||
import { Upload, ModifyUpload } from "@gradio/upload";
|
||||
import { upload, prepare_files, type FileData } from "@gradio/client";
|
||||
import {
|
||||
upload,
|
||||
prepare_files,
|
||||
type FileData,
|
||||
type upload_files
|
||||
} from "@gradio/client";
|
||||
import { BlockLabel } from "@gradio/atoms";
|
||||
import { Music } from "@gradio/icons";
|
||||
import AudioPlayer from "../player/AudioPlayer.svelte";
|
||||
@ -33,6 +38,9 @@
|
||||
export let handle_reset_value: () => void = () => {};
|
||||
export let editable = true;
|
||||
|
||||
// Needed for wasm support
|
||||
const upload_fn = getContext<typeof upload_files>("upload_files");
|
||||
|
||||
$: dispatch("drag", dragging);
|
||||
|
||||
// TODO: make use of this
|
||||
@ -87,7 +95,11 @@
|
||||
): Promise<void> => {
|
||||
let _audio_blob = new File(blobs, "audio.wav");
|
||||
const val = await prepare_files([_audio_blob], event === "stream");
|
||||
value = ((await upload(val, root))?.filter(Boolean) as FileData[])[0];
|
||||
value = (
|
||||
(await upload(val, root, undefined, upload_fn))?.filter(
|
||||
Boolean
|
||||
) as FileData[]
|
||||
)[0];
|
||||
|
||||
dispatch(event, value);
|
||||
};
|
||||
|
@ -57,9 +57,13 @@
|
||||
const create_waveform = (): void => {
|
||||
waveform = WaveSurfer.create({
|
||||
container: container,
|
||||
url: value?.url,
|
||||
...waveform_settings
|
||||
});
|
||||
resolve_wasm_src(value?.url).then((resolved_src) => {
|
||||
if (resolved_src && waveform) {
|
||||
return waveform.load(resolved_src);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
$: if (container !== undefined) {
|
||||
|
@ -7,6 +7,7 @@
|
||||
import AudioPlayer from "../player/AudioPlayer.svelte";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import type { FileData } from "@gradio/client";
|
||||
import { DownloadLink } from "@gradio/wasm/svelte";
|
||||
import type { WaveformOptions } from "../shared/types";
|
||||
|
||||
export let value: null | FileData = null;
|
||||
@ -39,13 +40,9 @@
|
||||
{#if value !== null}
|
||||
<div class="icon-buttons">
|
||||
{#if show_download_button}
|
||||
<a
|
||||
href={value.url}
|
||||
target={window.__is_colab__ ? "_blank" : null}
|
||||
download={value.orig_name || value.path}
|
||||
>
|
||||
<DownloadLink href={value.url} download={value.orig_name || value.path}>
|
||||
<IconButton Icon={Download} label={i18n("common.download")} />
|
||||
</a>
|
||||
</DownloadLink>
|
||||
{/if}
|
||||
{#if show_share_button}
|
||||
<ShareButton
|
||||
|
@ -26,6 +26,7 @@
|
||||
"@gradio/statustracker": "workspace:^",
|
||||
"@gradio/upload": "workspace:^",
|
||||
"@gradio/utils": "workspace:^",
|
||||
"@gradio/wasm": "workspace:^",
|
||||
"@lezer/common": "^1.0.2",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/markdown": "^1.0.2",
|
||||
|
@ -2,6 +2,7 @@
|
||||
import { onDestroy } from "svelte";
|
||||
import { fade } from "svelte/transition";
|
||||
import { Download, Check } from "@gradio/icons";
|
||||
import { DownloadLink } from "@gradio/wasm/svelte";
|
||||
|
||||
export let value: string;
|
||||
export let language: string;
|
||||
@ -50,20 +51,21 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<a
|
||||
download="file.{ext}"
|
||||
href={download_value}
|
||||
class:copied
|
||||
on:click={copy_feedback}
|
||||
>
|
||||
<Download />
|
||||
{#if copied}
|
||||
<span class="check" transition:fade><Check /></span>
|
||||
{/if}
|
||||
</a>
|
||||
<div class="container">
|
||||
<DownloadLink
|
||||
download="file.{ext}"
|
||||
href={download_value}
|
||||
on:click={copy_feedback}
|
||||
>
|
||||
<Download />
|
||||
{#if copied}
|
||||
<span class="check" transition:fade><Check /></span>
|
||||
{/if}
|
||||
</DownloadLink>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
a {
|
||||
.container {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
@ -72,10 +74,6 @@
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.copied {
|
||||
color: var(--color-green-500);
|
||||
}
|
||||
|
||||
.check {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
@ -12,7 +12,8 @@
|
||||
"@gradio/icons": "workspace:^",
|
||||
"@gradio/statustracker": "workspace:^",
|
||||
"@gradio/upload": "workspace:^",
|
||||
"@gradio/utils": "workspace:^"
|
||||
"@gradio/utils": "workspace:^",
|
||||
"@gradio/wasm": "workspace:^"
|
||||
},
|
||||
"main": "./Index.svelte",
|
||||
"main_changeset": true,
|
||||
|
@ -3,6 +3,7 @@
|
||||
import { prettyBytes } from "./utils";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import type { I18nFormatter, SelectData } from "@gradio/utils";
|
||||
import { DownloadLink } from "@gradio/wasm/svelte";
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
select: SelectData;
|
||||
@ -53,15 +54,14 @@
|
||||
|
||||
<td class="download">
|
||||
{#if file.url}
|
||||
<a
|
||||
<DownloadLink
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
download={window.__is_colab__ ? null : file.orig_name}
|
||||
>
|
||||
{@html file.size != null
|
||||
? prettyBytes(file.size)
|
||||
: "(size unknown)"} ⇣
|
||||
</a>
|
||||
</DownloadLink>
|
||||
{:else}
|
||||
{i18n("file.uploading")}
|
||||
{/if}
|
||||
@ -114,17 +114,17 @@
|
||||
.download:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.download > a {
|
||||
.download > :global(a) {
|
||||
color: var(--link-text-color);
|
||||
}
|
||||
|
||||
.download > a:hover {
|
||||
.download > :global(a:hover) {
|
||||
color: var(--link-text-color-hover);
|
||||
}
|
||||
.download > a:visited {
|
||||
.download > :global(a:visited) {
|
||||
color: var(--link-text-color-visited);
|
||||
}
|
||||
.download > a:active {
|
||||
.download > :global(a:active) {
|
||||
color: var(--link-text-color-active);
|
||||
}
|
||||
.selectable {
|
||||
|
@ -5,11 +5,30 @@
|
||||
import { resolve_wasm_src } from "@gradio/wasm/svelte";
|
||||
|
||||
export let src: HTMLImgAttributes["src"] = undefined;
|
||||
|
||||
let resolved_src: typeof src;
|
||||
|
||||
// The `src` prop can be updated before the Promise from `resolve_wasm_src` is resolved.
|
||||
// In such a case, the resolved value for the old `src` has to be discarded,
|
||||
// This variable `latest_src` is used to pick up only the value resolved for the latest `src` prop.
|
||||
let latest_src: typeof src;
|
||||
$: {
|
||||
// In normal (non-Wasm) Gradio, the `<img>` element should be rendered with the passed `src` props immediately
|
||||
// without waiting for `resolve_wasm_src()` to resolve.
|
||||
// If it waits, a black image is displayed until the async task finishes
|
||||
// and it leads to undesirable flickering.
|
||||
// So set `src` to `resolved_src` here.
|
||||
resolved_src = src;
|
||||
|
||||
latest_src = src;
|
||||
const resolving_src = src;
|
||||
resolve_wasm_src(resolving_src).then((s) => {
|
||||
if (latest_src === resolving_src) {
|
||||
resolved_src = s;
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await resolve_wasm_src(src) then resolved_src}
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<img src={resolved_src} {...$$restProps} />
|
||||
{:catch error}
|
||||
<p style="color: red;">{error.message}</p>
|
||||
{/await}
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<img src={resolved_src} {...$$restProps} />
|
||||
|
@ -5,8 +5,10 @@
|
||||
import { BlockLabel, Empty, IconButton, ShareButton } from "@gradio/atoms";
|
||||
import { Download } from "@gradio/icons";
|
||||
import { get_coordinates_of_clicked_image } from "./utils";
|
||||
import Image from "./Image.svelte";
|
||||
import { DownloadLink } from "@gradio/wasm/svelte";
|
||||
|
||||
import { Image } from "@gradio/icons";
|
||||
import { Image as ImageIcon } from "@gradio/icons";
|
||||
import { type FileData, normalise_file } from "@gradio/client";
|
||||
import type { I18nFormatter } from "@gradio/utils";
|
||||
|
||||
@ -31,19 +33,19 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<BlockLabel {show_label} Icon={Image} label={label || i18n("image.image")} />
|
||||
<BlockLabel
|
||||
{show_label}
|
||||
Icon={ImageIcon}
|
||||
label={label || i18n("image.image")}
|
||||
/>
|
||||
{#if value === null || !value.url}
|
||||
<Empty unpadded_box={true} size="large"><Image /></Empty>
|
||||
<Empty unpadded_box={true} size="large"><ImageIcon /></Empty>
|
||||
{:else}
|
||||
<div class="icon-buttons">
|
||||
{#if show_download_button}
|
||||
<a
|
||||
href={value.url}
|
||||
target={window.__is_colab__ ? "_blank" : null}
|
||||
download={value.orig_name || "image"}
|
||||
>
|
||||
<DownloadLink href={value.url} download={value.orig_name || "image"}>
|
||||
<IconButton Icon={Download} label={i18n("common.download")} />
|
||||
</a>
|
||||
</DownloadLink>
|
||||
{/if}
|
||||
{#if show_share_button}
|
||||
<ShareButton
|
||||
@ -60,12 +62,14 @@
|
||||
{/if}
|
||||
</div>
|
||||
<button on:click={handle_click}>
|
||||
<img src={value.url} alt="" class:selectable loading="lazy" />
|
||||
<div class:selectable class="image-container">
|
||||
<Image src={value.url} alt="" loading="lazy" />
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
img,
|
||||
.image-container :global(img),
|
||||
button {
|
||||
width: var(--size-full);
|
||||
height: var(--size-full);
|
||||
|
@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, tick } from "svelte";
|
||||
import { BlockLabel } from "@gradio/atoms";
|
||||
import { Image } from "@gradio/icons";
|
||||
import { Image as ImageIcon } from "@gradio/icons";
|
||||
import type { SelectData, I18nFormatter } from "@gradio/utils";
|
||||
import { get_coordinates_of_clicked_image } from "./utils";
|
||||
import {
|
||||
@ -15,6 +15,7 @@
|
||||
import { Upload } from "@gradio/upload";
|
||||
import { type FileData, normalise_file } from "@gradio/client";
|
||||
import ClearImage from "./ClearImage.svelte";
|
||||
import Image from "./Image.svelte";
|
||||
|
||||
export let value: null | FileData;
|
||||
export let label: string | undefined = undefined;
|
||||
@ -140,7 +141,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<BlockLabel {show_label} Icon={Image} label={label || "Image"} />
|
||||
<BlockLabel {show_label} Icon={ImageIcon} label={label || "Image"} />
|
||||
|
||||
<div data-testid="image" class="image-container">
|
||||
{#if value?.url}
|
||||
@ -182,13 +183,10 @@
|
||||
/>
|
||||
{:else if value !== null && !streaming}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events-->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions-->
|
||||
<img
|
||||
src={value.url}
|
||||
alt={value.alt_text}
|
||||
on:click={handle_click}
|
||||
class:selectable
|
||||
/>
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions-->
|
||||
<div class:selectable class="image-frame" on:click={handle_click}>
|
||||
<Image src={value.url} alt={value.alt_text} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if sources.length > 1 || sources.includes("clipboard")}
|
||||
@ -207,10 +205,7 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* .image-container {
|
||||
height: auto;
|
||||
} */
|
||||
img {
|
||||
.image-frame :global(img) {
|
||||
width: var(--size-full);
|
||||
height: var(--size-full);
|
||||
}
|
||||
|
@ -22,50 +22,69 @@
|
||||
export let processingVideo = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let resolved_src: typeof src;
|
||||
|
||||
// The `src` prop can be updated before the Promise from `resolve_wasm_src` is resolved.
|
||||
// In such a case, the resolved value for the old `src` has to be discarded,
|
||||
// This variable `latest_src` is used to pick up only the value resolved for the latest `src` prop.
|
||||
let latest_src: typeof src;
|
||||
$: {
|
||||
// In normal (non-Wasm) Gradio, the `<img>` element should be rendered with the passed `src` props immediately
|
||||
// without waiting for `resolve_wasm_src()` to resolve.
|
||||
// If it waits, a black image is displayed until the async task finishes
|
||||
// and it leads to undesirable flickering.
|
||||
// So set `src` to `resolved_src` here.
|
||||
resolved_src = src;
|
||||
|
||||
latest_src = src;
|
||||
const resolving_src = src;
|
||||
resolve_wasm_src(resolving_src).then((s) => {
|
||||
if (latest_src === resolving_src) {
|
||||
resolved_src = s;
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await resolve_wasm_src(src) then resolved_src}
|
||||
<!--
|
||||
The spread operator with `$$props` or `$$restProps` can't be used here
|
||||
to pass props from the parent component to the <video> element
|
||||
because of its unexpected behavior: https://github.com/sveltejs/svelte/issues/7404
|
||||
For example, if we add {...$$props} or {...$$restProps}, the boolean props aside it like `controls` will be compiled as string "true" or "false" on the actual DOM.
|
||||
Then, even when `controls` is false, the compiled DOM would be `<video controls="false">` which is equivalent to `<video controls>` since the string "false" is even truthy.
|
||||
<!--
|
||||
The spread operator with `$$props` or `$$restProps` can't be used here
|
||||
to pass props from the parent component to the <video> element
|
||||
because of its unexpected behavior: https://github.com/sveltejs/svelte/issues/7404
|
||||
For example, if we add {...$$props} or {...$$restProps}, the boolean props aside it like `controls` will be compiled as string "true" or "false" on the actual DOM.
|
||||
Then, even when `controls` is false, the compiled DOM would be `<video controls="false">` which is equivalent to `<video controls>` since the string "false" is even truthy.
|
||||
-->
|
||||
<div class:hidden={!processingVideo} class="overlay">
|
||||
<span class="load-wrap">
|
||||
<span class="loader" />
|
||||
</span>
|
||||
</div>
|
||||
<video
|
||||
src={resolved_src}
|
||||
{muted}
|
||||
{playsinline}
|
||||
{preload}
|
||||
{autoplay}
|
||||
{controls}
|
||||
on:loadeddata={dispatch.bind(null, "loadeddata")}
|
||||
on:click={dispatch.bind(null, "click")}
|
||||
on:play={dispatch.bind(null, "play")}
|
||||
on:pause={dispatch.bind(null, "pause")}
|
||||
on:ended={dispatch.bind(null, "ended")}
|
||||
on:mouseover={dispatch.bind(null, "mouseover")}
|
||||
on:mouseout={dispatch.bind(null, "mouseout")}
|
||||
on:focus={dispatch.bind(null, "focus")}
|
||||
on:blur={dispatch.bind(null, "blur")}
|
||||
bind:currentTime
|
||||
bind:duration
|
||||
bind:paused
|
||||
bind:this={node}
|
||||
use:loaded={{ autoplay: autoplay ?? false }}
|
||||
data-testid={$$props["data-testid"]}
|
||||
crossorigin="anonymous"
|
||||
>
|
||||
<slot />
|
||||
</video>
|
||||
{:catch error}
|
||||
<p style="color: red;">{error.message}</p>
|
||||
{/await}
|
||||
<div class:hidden={!processingVideo} class="overlay">
|
||||
<span class="load-wrap">
|
||||
<span class="loader" />
|
||||
</span>
|
||||
</div>
|
||||
<video
|
||||
src={resolved_src}
|
||||
{muted}
|
||||
{playsinline}
|
||||
{preload}
|
||||
{autoplay}
|
||||
{controls}
|
||||
on:loadeddata={dispatch.bind(null, "loadeddata")}
|
||||
on:click={dispatch.bind(null, "click")}
|
||||
on:play={dispatch.bind(null, "play")}
|
||||
on:pause={dispatch.bind(null, "pause")}
|
||||
on:ended={dispatch.bind(null, "ended")}
|
||||
on:mouseover={dispatch.bind(null, "mouseover")}
|
||||
on:mouseout={dispatch.bind(null, "mouseout")}
|
||||
on:focus={dispatch.bind(null, "focus")}
|
||||
on:blur={dispatch.bind(null, "blur")}
|
||||
bind:currentTime
|
||||
bind:duration
|
||||
bind:paused
|
||||
bind:this={node}
|
||||
use:loaded={{ autoplay: autoplay ?? false }}
|
||||
data-testid={$$props["data-testid"]}
|
||||
crossorigin="anonymous"
|
||||
>
|
||||
<slot />
|
||||
</video>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
|
@ -4,6 +4,7 @@
|
||||
import type { FileData } from "@gradio/client";
|
||||
import { Video, Download } from "@gradio/icons";
|
||||
import { uploadToHuggingFace } from "@gradio/utils";
|
||||
import { DownloadLink } from "@gradio/wasm/svelte";
|
||||
|
||||
import Player from "./Player.svelte";
|
||||
import type { I18nFormatter } from "js/app/src/gradio_helper";
|
||||
@ -67,13 +68,9 @@
|
||||
{/key}
|
||||
<div class="icon-buttons" data-testid="download-div">
|
||||
{#if show_download_button}
|
||||
<a
|
||||
href={value.url}
|
||||
target={window.__is_colab__ ? "_blank" : null}
|
||||
download={value.orig_name || value.path}
|
||||
>
|
||||
<DownloadLink href={value.url} download={value.orig_name || value.path}>
|
||||
<IconButton Icon={Download} label="Download" />
|
||||
</a>
|
||||
</DownloadLink>
|
||||
{/if}
|
||||
{#if show_share_button}
|
||||
<ShareButton
|
||||
|
37
js/wasm/src/asgi-types.ts
Normal file
37
js/wasm/src/asgi-types.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import type { PyProxy } from "pyodide/ffi";
|
||||
|
||||
// A reference to an ASGI application instance in Python
|
||||
// Ref: https://asgi.readthedocs.io/en/latest/specs/main.html#applications
|
||||
export type ASGIScope = Record<string, unknown>;
|
||||
export type ASGIApplication = (
|
||||
scope: ASGIScope,
|
||||
receive: () => Promise<ReceiveEvent>,
|
||||
send: (event: PyProxy) => Promise<void> // `event` is a `SendEvent` dict in Python and passed as a `PyProxy` in JS via Pyodide's type conversion (https://pyodide.org/en/stable/usage/type-conversions.html#type-translations-pyproxy-to-js).
|
||||
) => Promise<void>;
|
||||
|
||||
export type ReceiveEvent = RequestReceiveEvent | DisconnectReceiveEvent;
|
||||
// https://asgi.readthedocs.io/en/latest/specs/www.html#request-receive-event
|
||||
export interface RequestReceiveEvent {
|
||||
type: "http.request";
|
||||
body?: Uint8Array; // `bytes` in Python
|
||||
more_body?: boolean;
|
||||
}
|
||||
// https://asgi.readthedocs.io/en/latest/specs/www.html#disconnect-receive-event
|
||||
export interface DisconnectReceiveEvent {
|
||||
type: "http.disconnect";
|
||||
}
|
||||
|
||||
export type SendEvent = ResponseStartSendEvent | ResponseBodySendEvent;
|
||||
// https://asgi.readthedocs.io/en/latest/specs/www.html#response-start-send-event
|
||||
export interface ResponseStartSendEvent {
|
||||
type: "http.response.start";
|
||||
status: number;
|
||||
headers: Iterable<[Uint8Array, Uint8Array]>;
|
||||
trailers: boolean;
|
||||
}
|
||||
// https://asgi.readthedocs.io/en/latest/specs/www.html#response-body-send-event
|
||||
export interface ResponseBodySendEvent {
|
||||
type: "http.response.body";
|
||||
body: Uint8Array; // `bytes` in Python
|
||||
more_body: boolean;
|
||||
}
|
77
js/wasm/src/http.ts
Normal file
77
js/wasm/src/http.ts
Normal file
@ -0,0 +1,77 @@
|
||||
export interface HttpRequest {
|
||||
method: "GET" | "POST" | "PUT" | "DELETE";
|
||||
path: string;
|
||||
query_string: string;
|
||||
headers: Record<string, string>;
|
||||
body?: Uint8Array;
|
||||
}
|
||||
export interface HttpResponse {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: Uint8Array;
|
||||
}
|
||||
|
||||
// Inspired by https://github.com/rstudio/shinylive/blob/v0.1.2/src/messageporthttp.ts
|
||||
export function headersToASGI(
|
||||
headers: HttpRequest["headers"]
|
||||
): [string, string][] {
|
||||
const result: [string, string][] = [];
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
result.push([key, value]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function uint8ArrayToString(buf: Uint8Array): string {
|
||||
let result = "";
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
result += String.fromCharCode(buf[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function asgiHeadersToRecord(headers: any): Record<string, string> {
|
||||
headers = headers.map(([key, val]: [Uint8Array, Uint8Array]) => {
|
||||
return [uint8ArrayToString(key), uint8ArrayToString(val)];
|
||||
});
|
||||
return Object.fromEntries(headers);
|
||||
}
|
||||
|
||||
export function getHeaderValue(
|
||||
headers: HttpRequest["headers"],
|
||||
key: string
|
||||
): string | undefined {
|
||||
// The keys in `headers` are case-insensitive.
|
||||
const unifiedKey = key.toLowerCase();
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
if (k.toLowerCase() === unifiedKey) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function logHttpReqRes(
|
||||
request: HttpRequest,
|
||||
response: HttpResponse
|
||||
): void {
|
||||
if (Math.floor(response.status / 100) !== 2) {
|
||||
let bodyText: string;
|
||||
let bodyJson: unknown;
|
||||
try {
|
||||
bodyText = new TextDecoder().decode(response.body);
|
||||
} catch (e) {
|
||||
bodyText = "(failed to decode body)";
|
||||
}
|
||||
try {
|
||||
bodyJson = JSON.parse(bodyText);
|
||||
} catch (e) {
|
||||
bodyJson = "(failed to parse body as JSON)";
|
||||
}
|
||||
console.error("Wasm HTTP error", {
|
||||
request,
|
||||
response,
|
||||
bodyText,
|
||||
bodyJson
|
||||
});
|
||||
}
|
||||
}
|
@ -1 +1,2 @@
|
||||
export { WorkerProxy, type WorkerProxyOptions } from "./worker-proxy";
|
||||
export { WasmWorkerEventSource } from "./sse";
|
||||
|
@ -1,15 +1,5 @@
|
||||
export interface HttpRequest {
|
||||
method: "GET" | "POST" | "PUT" | "DELETE";
|
||||
path: string;
|
||||
query_string: string;
|
||||
headers: Record<string, string>;
|
||||
body?: Uint8Array;
|
||||
}
|
||||
export interface HttpResponse {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: Uint8Array;
|
||||
}
|
||||
import type { ASGIScope } from "./asgi-types";
|
||||
|
||||
export interface EmscriptenFile {
|
||||
data: string | ArrayBufferView;
|
||||
opts?: Record<string, string>;
|
||||
@ -50,16 +40,10 @@ export interface InMessageRunPythonFile extends InMessageBase {
|
||||
path: string;
|
||||
};
|
||||
}
|
||||
export interface InMessageHttpRequest extends InMessageBase {
|
||||
type: "http-request";
|
||||
export interface InMessageAsgiRequest extends InMessageBase {
|
||||
type: "asgi-request";
|
||||
data: {
|
||||
request: HttpRequest;
|
||||
};
|
||||
}
|
||||
export interface InMessageWebSocket extends InMessageBase {
|
||||
type: "websocket";
|
||||
data: {
|
||||
path: string;
|
||||
scope: ASGIScope;
|
||||
};
|
||||
}
|
||||
export interface InMessageFileWrite extends InMessageBase {
|
||||
@ -101,8 +85,7 @@ export type InMessage =
|
||||
| InMessageInitApp
|
||||
| InMessageRunPythonCode
|
||||
| InMessageRunPythonFile
|
||||
| InMessageWebSocket
|
||||
| InMessageHttpRequest
|
||||
| InMessageAsgiRequest
|
||||
| InMessageFileWrite
|
||||
| InMessageFileRename
|
||||
| InMessageFileUnlink
|
||||
|
@ -1,152 +0,0 @@
|
||||
// Copied from https://github.com/rstudio/shinylive/blob/v0.1.4/src/messageportwebsocket.ts
|
||||
// and modified.
|
||||
/*
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 RStudio, PBC
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This class provides a standard WebSocket API, but is implemented using a
|
||||
* MessagePort. It can represent the server or client side of a WebSocket
|
||||
* connection. If server, then ws.accept() should be called after creation
|
||||
* in order to initialize the connection.
|
||||
*/
|
||||
export class MessagePortWebSocket extends EventTarget {
|
||||
readyState: number;
|
||||
_port: MessagePort;
|
||||
onopen: ((this: MessagePortWebSocket, ev: Event) => any) | undefined;
|
||||
onmessage:
|
||||
| ((this: MessagePortWebSocket, ev: MessageEvent) => any)
|
||||
| undefined;
|
||||
onerror: ((this: MessagePortWebSocket, ev: Event) => any) | undefined;
|
||||
onclose: ((this: MessagePortWebSocket, ev: CloseEvent) => any) | undefined;
|
||||
|
||||
constructor(port: MessagePort) {
|
||||
super();
|
||||
|
||||
this.readyState = 0;
|
||||
|
||||
this.addEventListener("open", (e) => {
|
||||
if (this.onopen) {
|
||||
this.onopen(e);
|
||||
}
|
||||
});
|
||||
this.addEventListener("message", (e) => {
|
||||
if (this.onmessage) {
|
||||
this.onmessage(e as MessageEvent);
|
||||
}
|
||||
});
|
||||
this.addEventListener("error", (e) => {
|
||||
if (this.onerror) {
|
||||
this.onerror(e);
|
||||
}
|
||||
});
|
||||
this.addEventListener("close", (e) => {
|
||||
if (this.onclose) {
|
||||
this.onclose(e as CloseEvent);
|
||||
}
|
||||
});
|
||||
|
||||
this._port = port;
|
||||
port.addEventListener("message", this._onMessage.bind(this));
|
||||
port.start();
|
||||
}
|
||||
|
||||
// Call on the server side of the connection, to tell the client that
|
||||
// the connection has been established.
|
||||
public accept(): void {
|
||||
if (this.readyState !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.readyState = 1;
|
||||
this._port.postMessage({ type: "open" });
|
||||
}
|
||||
|
||||
public send(data: unknown): void {
|
||||
if (this.readyState === 0) {
|
||||
throw new DOMException(
|
||||
"Can't send messages while WebSocket is in CONNECTING state",
|
||||
"InvalidStateError"
|
||||
);
|
||||
}
|
||||
if (this.readyState > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._port.postMessage({ type: "message", value: { data } });
|
||||
}
|
||||
|
||||
public close(code?: number, reason?: string): void {
|
||||
if (this.readyState > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.readyState = 2;
|
||||
this._port.postMessage({ type: "close", value: { code, reason } });
|
||||
this.readyState = 3;
|
||||
this.dispatchEvent(
|
||||
new CloseEvent("close", { code, reason, wasClean: true })
|
||||
);
|
||||
}
|
||||
|
||||
private _onMessage(e: MessageEvent): void {
|
||||
const event = e.data;
|
||||
console.debug("MessagePortWebSocket received event:", event);
|
||||
switch (event.type) {
|
||||
case "open":
|
||||
if (this.readyState === 0) {
|
||||
this.readyState = 1;
|
||||
this.dispatchEvent(new Event("open"));
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case "message":
|
||||
if (this.readyState === 1) {
|
||||
this.dispatchEvent(new MessageEvent("message", { ...event.value }));
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case "close":
|
||||
if (this.readyState < 3) {
|
||||
this.readyState = 3;
|
||||
this.dispatchEvent(
|
||||
new CloseEvent("close", { ...event.value, wasClean: true })
|
||||
);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// If we got here, we didn't know how to handle this event
|
||||
this._reportError(
|
||||
`Unexpected event '${event.type}' while in readyState ${this.readyState}`,
|
||||
1002
|
||||
);
|
||||
}
|
||||
|
||||
private _reportError(message: string, code?: number): void {
|
||||
this.dispatchEvent(new ErrorEvent("error", { message }));
|
||||
if (typeof code === "number") {
|
||||
this.close(code, message);
|
||||
}
|
||||
}
|
||||
}
|
212
js/wasm/src/sse.ts
Normal file
212
js/wasm/src/sse.ts
Normal file
@ -0,0 +1,212 @@
|
||||
import { asgiHeadersToRecord, getHeaderValue } from "./http";
|
||||
import type { ASGIScope, ReceiveEvent, SendEvent } from "./asgi-types";
|
||||
import type { WorkerProxy } from "./worker-proxy";
|
||||
|
||||
export class WasmWorkerEventSource extends EventTarget {
|
||||
/**
|
||||
* 0 — connecting
|
||||
* 1 — open
|
||||
* 2 — closed
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/EventSource/readyState
|
||||
*/
|
||||
public readyState: number;
|
||||
|
||||
private port: MessagePort;
|
||||
private url: URL;
|
||||
|
||||
// This class partially implements the EventSource interface (https://html.spec.whatwg.org/multipage/server-sent-events.html#the-eventsource-interface).
|
||||
// Reconnection is not implemented, so this class doesn't maitain a reconnection time and a last event ID string and the stream interpreter ignores the field related to these, e.g. "id" and "retry".
|
||||
|
||||
onopen: ((this: WasmWorkerEventSource, ev: Event) => any) | undefined;
|
||||
onmessage:
|
||||
| ((this: WasmWorkerEventSource, ev: MessageEvent) => any)
|
||||
| undefined;
|
||||
onerror: ((this: WasmWorkerEventSource, ev: Event) => any) | undefined;
|
||||
|
||||
constructor(workerProxy: WorkerProxy, url: URL) {
|
||||
super();
|
||||
this.url = url;
|
||||
this.readyState = 0;
|
||||
|
||||
this.addEventListener("open", (e) => {
|
||||
if (this.onopen) {
|
||||
this.onopen(e);
|
||||
}
|
||||
});
|
||||
this.addEventListener("message", (e) => {
|
||||
if (this.onmessage) {
|
||||
this.onmessage(e as MessageEvent);
|
||||
}
|
||||
});
|
||||
this.addEventListener("error", (e) => {
|
||||
if (this.onerror) {
|
||||
this.onerror(e);
|
||||
}
|
||||
});
|
||||
|
||||
// https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope
|
||||
const asgiScope: ASGIScope = {
|
||||
type: "http",
|
||||
asgi: {
|
||||
version: "3.0",
|
||||
spec_version: "2.1"
|
||||
},
|
||||
http_version: "1.1",
|
||||
scheme: "http",
|
||||
method: "GET",
|
||||
path: url.pathname,
|
||||
query_string: url.searchParams.toString(),
|
||||
root_path: "",
|
||||
headers: [["accept", "text/event-stream"]]
|
||||
};
|
||||
|
||||
this.port = workerProxy.requestAsgi(asgiScope);
|
||||
this.port.addEventListener("message", this._handleAsgiSendEvent.bind(this));
|
||||
this.port.start();
|
||||
this.port.postMessage({
|
||||
type: "http.request"
|
||||
} satisfies ReceiveEvent);
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
if (this.readyState === 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.port.postMessage({
|
||||
type: "http.disconnect"
|
||||
} satisfies ReceiveEvent);
|
||||
this.port.close();
|
||||
this.readyState = 2;
|
||||
}
|
||||
|
||||
private _handleAsgiSendEvent(e: MessageEvent<SendEvent>): void {
|
||||
const asgiSendEvent: SendEvent = e.data;
|
||||
|
||||
if (asgiSendEvent.type === "http.response.start") {
|
||||
const status = asgiSendEvent.status;
|
||||
const headers = asgiHeadersToRecord(asgiSendEvent.headers);
|
||||
console.debug("[MessagePortEventSource] HTTP response start", {
|
||||
status,
|
||||
headers
|
||||
});
|
||||
const contentType = getHeaderValue(headers, "content-type");
|
||||
if (
|
||||
status !== 200 ||
|
||||
contentType == null ||
|
||||
contentType.split(";")[0] !== "text/event-stream"
|
||||
) {
|
||||
// Fail the connection (https://html.spec.whatwg.org/multipage/server-sent-events.html#fail-the-connection)
|
||||
// Queue a task which, if the readyState attribute is set to a value other than CLOSED, sets the readyState attribute to CLOSED and fires an event named error at the EventSource object. Once the user agent has failed the connection, it does not attempt to reconnect.
|
||||
this.readyState = 2;
|
||||
this.dispatchEvent(new Event("error"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Announce the connection (https://html.spec.whatwg.org/multipage/server-sent-events.html#announce-the-connection)
|
||||
// Queue a task which, if the readyState attribute is set to a value other than CLOSED, sets the readyState attribute to OPEN and fires an event named open at the EventSource object.
|
||||
this.readyState = 1;
|
||||
this.dispatchEvent(new Event("open"));
|
||||
} else if (asgiSendEvent.type === "http.response.body") {
|
||||
const body = new TextDecoder().decode(asgiSendEvent.body);
|
||||
console.debug("[MessagePortEventSource] HTTP response body", body);
|
||||
this.interpretEventStream(body);
|
||||
|
||||
if (!asgiSendEvent.more_body) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private interpretEventStream(streamContent: string): void {
|
||||
// This method implements the steps described in the following section of the spec:
|
||||
// "9.2.6 Interpreting an event stream" (https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation)
|
||||
const self = this;
|
||||
|
||||
// The stream must then be parsed by reading everything line by line, with a U+000D CARRIAGE RETURN U+000A LINE FEED (CRLF) character pair, a single U+000A LINE FEED (LF) character not preceded by a U+000D CARRIAGE RETURN (CR) character, and a single U+000D CARRIAGE RETURN (CR) character not followed by a U+000A LINE FEED (LF) character being the ways in which a line can end.
|
||||
const lines = streamContent.split(/\r\n|\n|\r/);
|
||||
let data = "";
|
||||
let eventType = "";
|
||||
let lastEventId = "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line === "") {
|
||||
// If the line is empty (a blank line)
|
||||
// Dispatch the event, as defined below.
|
||||
dispatchEvent();
|
||||
} else if (line.startsWith(":")) {
|
||||
// If the line starts with a U+003A COLON character (:)
|
||||
// Ignore the line.
|
||||
} else if (line.includes(":")) {
|
||||
// If the line contains a U+003A COLON character (:)
|
||||
// Collect the characters on the line before the first U+003A COLON character (:), and let field be that string.
|
||||
// Collect the characters on the line after the first U+003A COLON character (:), and let value be that string. If value starts with a U+0020 SPACE character, remove it from value.
|
||||
// Process the field using the steps described below, using field as the field name and value as the field value.
|
||||
const [field, ...rest] = line.split(":");
|
||||
const value = rest.join(":").trimStart();
|
||||
processField(field, value);
|
||||
} else {
|
||||
// Otherwise, the string is not empty but does not contain a U+003A COLON character (:)
|
||||
// Process the field using the steps described below, using the whole line as the field name, and the empty string as the field value.
|
||||
const field = line;
|
||||
const value = "";
|
||||
processField(field, value);
|
||||
}
|
||||
}
|
||||
// Once the end of the file is reached, any pending data must be discarded. (If the file ends in the middle of an event, before the final empty line, the incomplete event is not dispatched.)
|
||||
|
||||
function processField(name: string, value: string): void {
|
||||
// The steps to process the field given a field name and a field value depend on the field name, as given in the following list. Field names must be compared literally, with no case folding performed.
|
||||
if (name === "event") {
|
||||
// Set the event type buffer to field value.
|
||||
eventType = value;
|
||||
} else if (name === "data") {
|
||||
// Append the field value to the data buffer, then append a single U+000A LINE FEED (LF) character to the data buffer.
|
||||
data += value + "\n";
|
||||
} else if (name === "id") {
|
||||
// If the field value does not contain U+0000 NULL, then set the last event ID buffer to the field value. Otherwise, ignore the field.
|
||||
if (!value.includes("\0")) {
|
||||
lastEventId = value;
|
||||
}
|
||||
} else if (name === "retry") {
|
||||
// If the field value consists of only ASCII digits, then interpret the field value as an integer in base ten, and set the event stream's reconnection time to that integer. Otherwise, ignore the field.
|
||||
// XXX: This partial implementation of EventSource doesn't support reconnection, so the reconnection time is not set.
|
||||
} else {
|
||||
// The field is ignored.
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchEvent(): void {
|
||||
// When the user agent is required to dispatch the event, the user agent must process the data buffer, the event type buffer, and the last event ID buffer using steps appropriate for the user agent.
|
||||
// For web browsers, the appropriate steps to dispatch the event are as follows:
|
||||
// 1. Set the last event ID string of the event source to the value of the last event ID buffer. The buffer does not get reset, so the last event ID string of the event source remains set to this value until the next time it is set by the server.
|
||||
// XXX: This partial implementation of EventSource doesn't support reconnection, so the last event ID string of the event source is not set.
|
||||
// 2. If the data buffer is an empty string, set the data buffer and the event type buffer to the empty string and return.
|
||||
if (data === "") {
|
||||
data = "";
|
||||
eventType = "";
|
||||
return;
|
||||
}
|
||||
// 3. If the data buffer's last character is a U+000A LINE FEED (LF) character, then remove the last character from the data buffer.
|
||||
if (data.endsWith("\n")) {
|
||||
data = data.slice(0, -1);
|
||||
}
|
||||
// 4. Let event be the result of creating an event using MessageEvent, in the relevant realm of the EventSource object.
|
||||
// 5. Initialize event's type attribute to "message", its data attribute to data, its origin attribute to the serialization of the origin of the event stream's final URL (i.e., the URL after redirects), and its lastEventId attribute to the last event ID string of the event source.
|
||||
// 6. If the event type buffer has a value other than the empty string, change the type of the newly created event to equal the value of the event type buffer.
|
||||
const event = new MessageEvent(eventType === "" ? "message" : eventType, {
|
||||
data,
|
||||
lastEventId,
|
||||
origin: self.url.origin
|
||||
});
|
||||
// 7. Set the data buffer and the event type buffer to the empty string.
|
||||
data = "";
|
||||
eventType = "";
|
||||
// 8. Queue a task which, if the readyState attribute is set to a value other than CLOSED, dispatches the newly created event at the EventSource object.
|
||||
if (self.readyState !== 2) {
|
||||
console.debug("[MessagePortEventSource] dispatching event", event);
|
||||
self.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
35
js/wasm/src/webworker/asgi.ts
Normal file
35
js/wasm/src/webworker/asgi.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import type { PyProxy } from "pyodide/ffi";
|
||||
import { AwaitableQueue } from "./awaitable-queue";
|
||||
import type {
|
||||
ASGIApplication,
|
||||
ASGIScope,
|
||||
ReceiveEvent,
|
||||
SendEvent
|
||||
} from "../asgi-types";
|
||||
|
||||
// Connect the `messagePort` to the `asgiApp` so that
|
||||
// the `asgiApp` can receive ASGI events (`ReceiveEvent`) from the `messagePort`
|
||||
// and send ASGI events (`SendEvent`) to the `messagePort`.
|
||||
export function makeAsgiRequest(
|
||||
asgiApp: ASGIApplication,
|
||||
scope: ASGIScope,
|
||||
messagePort: MessagePort
|
||||
): Promise<void> {
|
||||
const receiveEventQueue = new AwaitableQueue<ReceiveEvent>();
|
||||
|
||||
messagePort.addEventListener("message", (event) => {
|
||||
receiveEventQueue.enqueue(event.data);
|
||||
});
|
||||
messagePort.start();
|
||||
|
||||
// Set up the ASGI application, passing it the `scope` and the `receive` and `send` functions.
|
||||
// Ref: https://asgi.readthedocs.io/en/latest/specs/main.html#applications
|
||||
async function receiveFromJs(): Promise<ReceiveEvent> {
|
||||
return await receiveEventQueue.dequeue();
|
||||
}
|
||||
async function sendToJs(proxiedEvent: PyProxy): Promise<void> {
|
||||
const event = Object.fromEntries(proxiedEvent.toJs()) as SendEvent;
|
||||
messagePort.postMessage(event);
|
||||
}
|
||||
return asgiApp(scope, receiveFromJs, sendToJs);
|
||||
}
|
@ -1,133 +0,0 @@
|
||||
import type { PyProxy } from "pyodide/ffi";
|
||||
import type { HttpRequest, HttpResponse } from "../message-types";
|
||||
|
||||
// Inspired by https://github.com/rstudio/shinylive/blob/v0.1.2/src/messageporthttp.ts
|
||||
|
||||
// A reference to an ASGI application instance in Python
|
||||
// Ref: https://asgi.readthedocs.io/en/latest/specs/main.html#applications
|
||||
type ASGIApplication = (
|
||||
scope: Record<string, unknown>,
|
||||
receive: () => Promise<ReceiveEvent>,
|
||||
send: (event: PyProxy) => Promise<void>
|
||||
) => Promise<void>;
|
||||
|
||||
type ReceiveEvent = RequestReceiveEvent | DisconnectReceiveEvent;
|
||||
// https://asgi.readthedocs.io/en/latest/specs/www.html#request-receive-event
|
||||
interface RequestReceiveEvent {
|
||||
type: "http.request";
|
||||
body?: Uint8Array; // `bytes` in Python
|
||||
more_body: boolean;
|
||||
}
|
||||
// https://asgi.readthedocs.io/en/latest/specs/www.html#disconnect-receive-event
|
||||
interface DisconnectReceiveEvent {
|
||||
type: "http.disconnect";
|
||||
}
|
||||
|
||||
type SendEvent = ResponseStartSendEvent | ResponseBodySendEvent;
|
||||
// https://asgi.readthedocs.io/en/latest/specs/www.html#response-start-send-event
|
||||
interface ResponseStartSendEvent {
|
||||
type: "http.response.start";
|
||||
status: number;
|
||||
headers: Iterable<[Uint8Array, Uint8Array]>;
|
||||
trailers: boolean;
|
||||
}
|
||||
// https://asgi.readthedocs.io/en/latest/specs/www.html#response-body-send-event
|
||||
interface ResponseBodySendEvent {
|
||||
type: "http.response.body";
|
||||
body: Uint8Array; // `bytes` in Python
|
||||
more_body: boolean;
|
||||
}
|
||||
|
||||
function headersToASGI(headers: HttpRequest["headers"]): [string, string][] {
|
||||
const result: [string, string][] = [];
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
result.push([key, value]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function uint8ArrayToString(buf: Uint8Array): string {
|
||||
let result = "";
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
result += String.fromCharCode(buf[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function asgiHeadersToRecord(headers: any): Record<string, string> {
|
||||
headers = headers.map(([key, val]: [Uint8Array, Uint8Array]) => {
|
||||
return [uint8ArrayToString(key), uint8ArrayToString(val)];
|
||||
});
|
||||
return Object.fromEntries(headers);
|
||||
}
|
||||
|
||||
export const makeHttpRequest = (
|
||||
asgiApp: ASGIApplication,
|
||||
request: HttpRequest
|
||||
): Promise<HttpResponse> =>
|
||||
new Promise((resolve, reject) => {
|
||||
let sent = false;
|
||||
async function receiveFromJs(): Promise<ReceiveEvent> {
|
||||
if (sent) {
|
||||
// NOTE: I implemented this block just referring to the spec. However, it is not reached in practice so it's not combat-proven.
|
||||
return {
|
||||
type: "http.disconnect"
|
||||
};
|
||||
}
|
||||
|
||||
const event: RequestReceiveEvent = {
|
||||
type: "http.request",
|
||||
more_body: false
|
||||
};
|
||||
if (request.body) {
|
||||
event.body = request.body;
|
||||
}
|
||||
|
||||
console.debug("receive", event);
|
||||
sent = true;
|
||||
return event;
|
||||
}
|
||||
|
||||
let status: number;
|
||||
let headers: { [key: string]: string };
|
||||
let body: Uint8Array = new Uint8Array();
|
||||
async function sendToJs(proxiedEvent: PyProxy): Promise<void> {
|
||||
const event = Object.fromEntries(proxiedEvent.toJs()) as SendEvent;
|
||||
console.debug("send", event);
|
||||
if (event.type === "http.response.start") {
|
||||
status = event.status;
|
||||
headers = asgiHeadersToRecord(event.headers);
|
||||
} else if (event.type === "http.response.body") {
|
||||
body = new Uint8Array([...body, ...event.body]);
|
||||
if (!event.more_body) {
|
||||
const response: HttpResponse = {
|
||||
status,
|
||||
headers,
|
||||
body
|
||||
};
|
||||
console.debug("HTTP response", response);
|
||||
resolve(response);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unhandled ASGI event: ${JSON.stringify(event)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope
|
||||
const scope = {
|
||||
type: "http",
|
||||
asgi: {
|
||||
version: "3.0",
|
||||
spec_version: "2.1"
|
||||
},
|
||||
http_version: "1.1",
|
||||
scheme: "http",
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
query_string: request.query_string,
|
||||
root_path: "",
|
||||
headers: headersToASGI(request.headers)
|
||||
};
|
||||
|
||||
asgiApp(scope, receiveFromJs, sendToJs).catch(reject);
|
||||
});
|
@ -18,8 +18,7 @@ import {
|
||||
resolveAppHomeBasedPath
|
||||
} from "./file";
|
||||
import { verifyRequirements } from "./requirements";
|
||||
import { makeHttpRequest } from "./http";
|
||||
import { initWebSocket } from "./websocket";
|
||||
import { makeAsgiRequest } from "./asgi";
|
||||
import { generateRandomString } from "./random";
|
||||
import scriptRunnerPySource from "./py/script_runner.py?raw";
|
||||
import unloadModulesPySource from "./py/unload_modules.py?raw";
|
||||
@ -80,9 +79,12 @@ async function initializeEnvironment(
|
||||
await micropip.install(["typing-extensions>=4.8.0"]); // Typing extensions needs to be installed first otherwise the versions from the pyodide lockfile is used which is incompatible with the latest fastapi.
|
||||
await micropip.install(["markdown-it-py[linkify]~=2.2.0"]); // On 3rd June 2023, markdown-it-py 3.0.0 has been released. The `gradio` package depends on its `>=2.0.0` version so its 3.x will be resolved. However, it conflicts with `mdit-py-plugins`'s dependency `markdown-it-py >=1.0.0,<3.0.0` and micropip currently can't resolve it. So we explicitly install the compatible version of the library here.
|
||||
await micropip.install(["anyio==3.*"]); // `fastapi` depends on `anyio>=3.4.0,<5` so its 4.* can be installed, but it conflicts with the anyio version `httpx` depends on, `==3.*`. Seems like micropip can't resolve it for now, so we explicitly install the compatible version of the library here.
|
||||
await micropip.add_mock_package("pydantic", "2.4.2"); // PydanticV2 is not supported on Pyodide yet. Mock it here for installing the `gradio` package to pass the version check. Then, install PydanticV1 below.
|
||||
await micropip.install.callKwargs(gradioWheelUrls, {
|
||||
keep_going: true
|
||||
});
|
||||
await micropip.remove_mock_package("pydantic");
|
||||
await micropip.install(["pydantic==1.*"]); // Pydantic is necessary for `gradio` to run, so install v1 here as a fallback. Some tricks has been introduced in `gradio/data_classes.py` to make it work with v1.
|
||||
console.debug("Gradio wheels are loaded.");
|
||||
|
||||
console.debug("Mocking os module methods.");
|
||||
@ -350,30 +352,13 @@ function setupMessageHandler(receiver: MessageTransceiver): void {
|
||||
messagePort.postMessage(replyMessage);
|
||||
break;
|
||||
}
|
||||
case "http-request": {
|
||||
const request = msg.data.request;
|
||||
const response = await makeHttpRequest(
|
||||
case "asgi-request": {
|
||||
console.debug("ASGI request", msg.data);
|
||||
makeAsgiRequest(
|
||||
call_asgi_app_from_js.bind(null, appId),
|
||||
request
|
||||
);
|
||||
const replyMessage: ReplyMessageSuccess = {
|
||||
type: "reply:success",
|
||||
data: {
|
||||
response
|
||||
}
|
||||
};
|
||||
messagePort.postMessage(replyMessage);
|
||||
break;
|
||||
}
|
||||
case "websocket": {
|
||||
const { path } = msg.data;
|
||||
|
||||
console.debug("Initialize a WebSocket connection: ", { path });
|
||||
initWebSocket(
|
||||
call_asgi_app_from_js.bind(null, appId),
|
||||
path,
|
||||
msg.data.scope,
|
||||
messagePort
|
||||
); // This promise is not awaited because it won't resolves until the WebSocket connection is closed.
|
||||
); // This promise is not awaited because it won't resolves until the HTTP connection is closed.
|
||||
break;
|
||||
}
|
||||
case "file:write": {
|
||||
|
@ -1,149 +0,0 @@
|
||||
import type { PyProxy } from "pyodide/ffi";
|
||||
import { AwaitableQueue } from "./awaitable-queue";
|
||||
import { MessagePortWebSocket } from "../messageportwebsocket";
|
||||
|
||||
// A reference to an ASGI application instance in Python
|
||||
// Ref: https://asgi.readthedocs.io/en/latest/specs/main.html#applications
|
||||
type ASGIApplication = (
|
||||
scope: Record<string, unknown>,
|
||||
receive: () => Promise<ReceiveEvent>,
|
||||
send: (event: PyProxy) => Promise<void>
|
||||
) => Promise<void>;
|
||||
|
||||
// https://asgi.readthedocs.io/en/latest/specs/www.html#connect-receive-event
|
||||
interface ConnectReceiveEvent {
|
||||
type: "websocket.connect";
|
||||
}
|
||||
|
||||
// https://asgi.readthedocs.io/en/latest/specs/www.html#accept-send-event
|
||||
interface AcceptSendEvent {
|
||||
type: "websocket.accept";
|
||||
subprotofcol?: string;
|
||||
headers?: Iterable<[Uint8Array, Uint8Array]>;
|
||||
}
|
||||
|
||||
// https://asgi.readthedocs.io/en/latest/specs/www.html#receive-receive-event
|
||||
interface ReceiveReceiveEvent {
|
||||
type: "websocket.receive";
|
||||
bytes?: Uint8Array;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
// https://asgi.readthedocs.io/en/latest/specs/www.html#send-send-event
|
||||
interface SendSendEvent {
|
||||
type: "websocket.send";
|
||||
bytes?: Uint8Array;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
// https://asgi.readthedocs.io/en/latest/specs/www.html#disconnect-receive-event-ws
|
||||
interface DisconnectReceiveEvent {
|
||||
type: "websocket.disconnect";
|
||||
code?: number;
|
||||
}
|
||||
|
||||
// https://asgi.readthedocs.io/en/latest/specs/www.html#close-send-event
|
||||
interface CloseSendEvent {
|
||||
type: "websocket.close";
|
||||
code?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type ReceiveEvent =
|
||||
| ConnectReceiveEvent
|
||||
| ReceiveReceiveEvent
|
||||
| DisconnectReceiveEvent;
|
||||
export type SendEvent = AcceptSendEvent | SendSendEvent | CloseSendEvent;
|
||||
|
||||
export function initWebSocket(
|
||||
asgiApp: ASGIApplication,
|
||||
path: string,
|
||||
messagePort: MessagePort
|
||||
): Promise<void> {
|
||||
const receiveEventQueue = new AwaitableQueue<ReceiveEvent>();
|
||||
|
||||
const websocket = new MessagePortWebSocket(messagePort);
|
||||
|
||||
websocket.addEventListener("message", (e) => {
|
||||
const me = e as MessageEvent;
|
||||
const asgiEvent: ReceiveReceiveEvent =
|
||||
typeof me.data === "string"
|
||||
? {
|
||||
type: "websocket.receive",
|
||||
text: me.data
|
||||
}
|
||||
: {
|
||||
type: "websocket.receive",
|
||||
bytes: me.data
|
||||
};
|
||||
receiveEventQueue.enqueue(asgiEvent);
|
||||
});
|
||||
websocket.addEventListener("close", (e) => {
|
||||
const ce = e as CloseEvent;
|
||||
const asgiEvent: DisconnectReceiveEvent = {
|
||||
type: "websocket.disconnect",
|
||||
code: ce.code
|
||||
};
|
||||
receiveEventQueue.enqueue(asgiEvent);
|
||||
});
|
||||
websocket.addEventListener("error", (e) => {
|
||||
console.error(e);
|
||||
});
|
||||
|
||||
// Enqueue an event to open the connection beforehand, which is
|
||||
// "Sent to the application when the client initially opens a connection and is about to finish the WebSocket handshake."
|
||||
// Ref: https://asgi.readthedocs.io/en/latest/specs/www.html#connect-receive-event
|
||||
receiveEventQueue.enqueue({
|
||||
type: "websocket.connect"
|
||||
});
|
||||
|
||||
// Set up the ASGI application, passing it the `scope` and the `receive` and `send` functions.
|
||||
// Ref: https://asgi.readthedocs.io/en/latest/specs/main.html#applications
|
||||
async function receiveFromJs(): Promise<ReceiveEvent> {
|
||||
return await receiveEventQueue.dequeue();
|
||||
}
|
||||
|
||||
async function sendToJs(proxiedEvent: PyProxy): Promise<void> {
|
||||
const event = Object.fromEntries(proxiedEvent.toJs()) as SendEvent;
|
||||
switch (event.type) {
|
||||
case "websocket.accept": {
|
||||
// Sent by the application when it wishes to accept an incoming connection.
|
||||
// Ref: https://asgi.readthedocs.io/en/latest/specs/www.html#accept-send-event
|
||||
websocket.accept();
|
||||
break;
|
||||
}
|
||||
case "websocket.send": {
|
||||
// Sent by the application to send a data message to the client.
|
||||
// Ref: https://asgi.readthedocs.io/en/latest/specs/www.html#send-send-event
|
||||
websocket.send(event.text ?? event.bytes);
|
||||
break;
|
||||
}
|
||||
case "websocket.close": {
|
||||
// Sent by the application to tell the server to close the connection.
|
||||
// https://asgi.readthedocs.io/en/latest/specs/www.html#close-send-event
|
||||
websocket.close(event.code, event.reason);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
websocket.close(1002, "ASGI protocol error");
|
||||
// @ts-expect-error
|
||||
throw new Error(`Unhandled ASGI event: ${event.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scope = {
|
||||
type: "websocket",
|
||||
asgi: {
|
||||
version: "3.0",
|
||||
spec_version: "2.1"
|
||||
},
|
||||
path,
|
||||
headers: [],
|
||||
query_string: "",
|
||||
root_path: "http://xxx:99999",
|
||||
client: ["", 0]
|
||||
};
|
||||
|
||||
return asgiApp(scope, receiveFromJs, sendToJs);
|
||||
}
|
@ -2,15 +2,20 @@ import { CrossOriginWorkerMaker as Worker } from "./cross-origin-worker";
|
||||
import type {
|
||||
EmscriptenFile,
|
||||
EmscriptenFileUrl,
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
InMessage,
|
||||
InMessageWebSocket,
|
||||
InMessageAsgiRequest,
|
||||
OutMessage,
|
||||
ReplyMessage
|
||||
} from "./message-types";
|
||||
import { MessagePortWebSocket } from "./messageportwebsocket";
|
||||
import { PromiseDelegate } from "./promise-delegate";
|
||||
import {
|
||||
type HttpRequest,
|
||||
type HttpResponse,
|
||||
asgiHeadersToRecord,
|
||||
headersToASGI,
|
||||
logHttpReqRes
|
||||
} from "./http";
|
||||
import type { ASGIScope, ReceiveEvent, SendEvent } from "./asgi-types";
|
||||
|
||||
export interface WorkerProxyOptions {
|
||||
gradioWheelUrl: string;
|
||||
@ -158,6 +163,25 @@ export class WorkerProxy extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize an ASGI protocol connection with the ASGI app.
|
||||
// The returned `MessagePort` is used to communicate with the ASGI app
|
||||
// via the `postMessage()` API and the `message` event.
|
||||
// `postMessage()` sends a `ReceiveEvent` to the ASGI app (Be careful not to send a `SendEvent`. This is an event the ASGI app "receives".)
|
||||
// The ASGI app sends a `SendEvent` to the `message` event.
|
||||
public requestAsgi(scope: Record<string, unknown>): MessagePort {
|
||||
const channel = new MessageChannel();
|
||||
|
||||
const msg: InMessageAsgiRequest = {
|
||||
type: "asgi-request",
|
||||
data: {
|
||||
scope
|
||||
}
|
||||
};
|
||||
this.postMessageTarget.postMessage(msg, [channel.port2]);
|
||||
|
||||
return channel.port1;
|
||||
}
|
||||
|
||||
public async httpRequest(request: HttpRequest): Promise<HttpResponse> {
|
||||
// Wait for the first run to be done
|
||||
// to avoid the "Gradio app has not been launched." error
|
||||
@ -166,50 +190,67 @@ export class WorkerProxy extends EventTarget {
|
||||
await this.firstRunPromiseDelegate.promise;
|
||||
|
||||
console.debug("WorkerProxy.httpRequest()", request);
|
||||
const result = await this.postMessageAsync({
|
||||
type: "http-request",
|
||||
data: {
|
||||
request
|
||||
}
|
||||
});
|
||||
const response = (result as { response: HttpResponse }).response;
|
||||
|
||||
if (Math.floor(response.status / 100) !== 2) {
|
||||
let bodyText: string;
|
||||
let bodyJson: unknown;
|
||||
try {
|
||||
bodyText = new TextDecoder().decode(response.body);
|
||||
} catch (e) {
|
||||
bodyText = "(failed to decode body)";
|
||||
}
|
||||
try {
|
||||
bodyJson = JSON.parse(bodyText);
|
||||
} catch (e) {
|
||||
bodyJson = "(failed to parse body as JSON)";
|
||||
}
|
||||
console.error("Wasm HTTP error", {
|
||||
request,
|
||||
response,
|
||||
bodyText,
|
||||
bodyJson
|
||||
// Dispatch an ASGI request to the ASGI app and gather the response data.
|
||||
return new Promise((resolve, reject) => {
|
||||
// https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope
|
||||
const asgiScope: ASGIScope = {
|
||||
type: "http",
|
||||
asgi: {
|
||||
version: "3.0",
|
||||
spec_version: "2.1"
|
||||
},
|
||||
http_version: "1.1",
|
||||
scheme: "http",
|
||||
method: request.method,
|
||||
path: decodeURIComponent(request.path),
|
||||
query_string: decodeURIComponent(request.query_string),
|
||||
root_path: "",
|
||||
headers: headersToASGI(request.headers)
|
||||
};
|
||||
|
||||
const asgiMessagePort = this.requestAsgi(asgiScope);
|
||||
|
||||
let status: number;
|
||||
let headers: { [key: string]: string };
|
||||
let body: Uint8Array = new Uint8Array();
|
||||
asgiMessagePort.addEventListener("message", (ev) => {
|
||||
const asgiSendEvent: SendEvent = ev.data;
|
||||
|
||||
console.debug("send from ASGIapp", asgiSendEvent);
|
||||
if (asgiSendEvent.type === "http.response.start") {
|
||||
status = asgiSendEvent.status;
|
||||
headers = asgiHeadersToRecord(asgiSendEvent.headers);
|
||||
} else if (asgiSendEvent.type === "http.response.body") {
|
||||
body = new Uint8Array([...body, ...asgiSendEvent.body]);
|
||||
if (!asgiSendEvent.more_body) {
|
||||
const response: HttpResponse = {
|
||||
status,
|
||||
headers,
|
||||
body
|
||||
};
|
||||
console.debug("HTTP response", response);
|
||||
|
||||
asgiMessagePort.postMessage({
|
||||
type: "http.disconnect"
|
||||
} satisfies ReceiveEvent);
|
||||
|
||||
logHttpReqRes(request, response);
|
||||
resolve(response);
|
||||
}
|
||||
} else {
|
||||
reject(`Unhandled ASGI event: ${JSON.stringify(asgiSendEvent)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
asgiMessagePort.start();
|
||||
|
||||
public openWebSocket(path: string): MessagePortWebSocket {
|
||||
const channel = new MessageChannel();
|
||||
|
||||
const msg: InMessageWebSocket = {
|
||||
type: "websocket",
|
||||
data: {
|
||||
path
|
||||
}
|
||||
};
|
||||
this.postMessageTarget.postMessage(msg, [channel.port2]);
|
||||
|
||||
return new MessagePortWebSocket(channel.port1);
|
||||
asgiMessagePort.postMessage({
|
||||
type: "http.request",
|
||||
more_body: false,
|
||||
body: request.body
|
||||
} satisfies ReceiveEvent);
|
||||
});
|
||||
}
|
||||
|
||||
public writeFile(
|
||||
|
32
js/wasm/svelte/DownloadLink.svelte
Normal file
32
js/wasm/svelte/DownloadLink.svelte
Normal file
@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
interface DownloadLinkAttributes
|
||||
extends Omit<HTMLAnchorAttributes, "target"> {
|
||||
download: NonNullable<HTMLAnchorAttributes["download"]>;
|
||||
}
|
||||
type $$Props = DownloadLinkAttributes;
|
||||
|
||||
import { resolve_wasm_src } from ".";
|
||||
|
||||
export let href: DownloadLinkAttributes["href"] = undefined;
|
||||
export let download: DownloadLinkAttributes["download"];
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
{#await resolve_wasm_src(href) then resolved_href}
|
||||
<a
|
||||
href={resolved_href}
|
||||
target={window.__is_colab__ ? "_blank" : null}
|
||||
rel="noopener noreferrer"
|
||||
{download}
|
||||
{...$$restProps}
|
||||
on:click={dispatch.bind(null, "click")}
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
{:catch error}
|
||||
<p style="color: red;">{error.message}</p>
|
||||
{/await}
|
@ -1,5 +1,6 @@
|
||||
import { getWorkerProxyContext } from "./context";
|
||||
import { is_self_host } from "../network/host";
|
||||
import { getHeaderValue } from "../src/http";
|
||||
|
||||
type MediaSrc = string | undefined | null;
|
||||
|
||||
@ -37,7 +38,7 @@ export async function resolve_wasm_src(src: MediaSrc): Promise<MediaSrc> {
|
||||
throw new Error(`Failed to get file ${path} from the Wasm worker.`);
|
||||
}
|
||||
const blob = new Blob([response.body], {
|
||||
type: response.headers["Content-Type"]
|
||||
type: getHeaderValue(response.headers, "content-type")
|
||||
});
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
return blobUrl;
|
||||
|
@ -1,2 +1,3 @@
|
||||
export * from "./context";
|
||||
export * from "./file-url";
|
||||
export { default as DownloadLink } from "./DownloadLink.svelte";
|
||||
|
@ -100,6 +100,6 @@
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"include": ["src/**/*", "../../globals.d.ts"],
|
||||
"exclude": ["src/webworker/**/*"] // The worker code is bundled by Vite separately. See its config file.
|
||||
}
|
||||
|
@ -717,6 +717,9 @@ importers:
|
||||
'@gradio/utils':
|
||||
specifier: workspace:^
|
||||
version: link:../utils
|
||||
'@gradio/wasm':
|
||||
specifier: workspace:^
|
||||
version: link:../wasm
|
||||
'@lezer/common':
|
||||
specifier: ^1.0.2
|
||||
version: 1.0.2
|
||||
@ -860,6 +863,9 @@ importers:
|
||||
'@gradio/utils':
|
||||
specifier: workspace:^
|
||||
version: link:../utils
|
||||
'@gradio/wasm':
|
||||
specifier: workspace:^
|
||||
version: link:../wasm
|
||||
|
||||
js/fileexplorer:
|
||||
dependencies:
|
||||
|
Loading…
Reference in New Issue
Block a user