@gradio/client 2.3.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +4 -2
  3. package/dist/__vite-browser-external-Cgmn0awE.js +7 -0
  4. package/dist/browser.js +1949 -1963
  5. package/dist/client.d.ts +5 -0
  6. package/dist/client.d.ts.map +1 -1
  7. package/dist/constants.d.ts +4 -0
  8. package/dist/constants.d.ts.map +1 -1
  9. package/dist/helpers/api_info.d.ts.map +1 -1
  10. package/dist/helpers/data.d.ts.map +1 -1
  11. package/dist/helpers/init_helpers.d.ts +11 -2
  12. package/dist/helpers/init_helpers.d.ts.map +1 -1
  13. package/dist/index.cjs +2584 -0
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2471 -2466
  17. package/dist/index.min.js +1962 -0
  18. package/dist/types.d.ts +29 -0
  19. package/dist/types.d.ts.map +1 -1
  20. package/dist/utils/duplicate.d.ts.map +1 -1
  21. package/dist/utils/predict.d.ts.map +1 -1
  22. package/dist/utils/run_history.d.ts +64 -0
  23. package/dist/utils/run_history.d.ts.map +1 -0
  24. package/dist/utils/stream.d.ts.map +1 -1
  25. package/dist/utils/submit.d.ts.map +1 -1
  26. package/package.json +11 -4
  27. package/scripts/verify_dist.mjs +46 -0
  28. package/src/client.ts +54 -5
  29. package/src/constants.ts +10 -0
  30. package/src/helpers/api_info.ts +46 -19
  31. package/src/helpers/data.ts +8 -3
  32. package/src/helpers/init_helpers.ts +54 -7
  33. package/src/index.ts +12 -0
  34. package/src/test/api_info.test.ts +77 -12
  35. package/src/test/data.test.ts +26 -3
  36. package/src/test/init.test.ts +21 -3
  37. package/src/test/init_helpers.test.ts +67 -1
  38. package/src/test/refresh.test.ts +31 -0
  39. package/src/test/run_history.test.ts +345 -0
  40. package/src/test/submit.test.ts +1 -3
  41. package/src/types.ts +29 -0
  42. package/src/utils/duplicate.ts +2 -0
  43. package/src/utils/predict.ts +14 -1
  44. package/src/utils/run_history.ts +520 -0
  45. package/src/utils/stream.ts +17 -4
  46. package/src/utils/submit.ts +105 -12
  47. package/vite.config.ts +20 -4
  48. package/dist/__vite-browser-external-DYxpcVy9.js +0 -4
@@ -1,10 +1,11 @@
1
- import type { Config } from "../types";
1
+ import type { ClientOptions, Config } from "../types";
2
2
  import {
3
3
  CONFIG_ERROR_MSG,
4
4
  CONFIG_URL,
5
5
  INVALID_CREDENTIALS_MSG,
6
6
  LOGIN_URL,
7
7
  MISSING_CREDENTIALS_MSG,
8
+ PRIVATE_SPACE_MSG,
8
9
  SPACE_METADATA_ERROR_MSG,
9
10
  UNAUTHORIZED_MSG
10
11
  } from "../constants";
@@ -53,6 +54,22 @@ export async function get_jwt(
53
54
  }
54
55
  }
55
56
 
57
+ /**
58
+ * The `hf_token` option was renamed to `token`, but a lot of existing code
59
+ * (and the Python client) still uses `hf_token`. Accept it as an alias so
60
+ * that authenticated requests are not silently sent without credentials,
61
+ * which previously surfaced as "Could not resolve app config" errors when
62
+ * connecting to private Spaces.
63
+ */
64
+ export function normalise_token_option(options: ClientOptions): void {
65
+ if (options.hf_token && !options.token) {
66
+ options.token = options.hf_token;
67
+ console.warn(
68
+ "The `hf_token` option has been renamed to `token`. Support for `hf_token` will be removed in a future version of @gradio/client."
69
+ );
70
+ }
71
+ }
72
+
56
73
  export function map_names_to_ids(
57
74
  fns: Config["dependencies"]
58
75
  ): Record<string, number> {
@@ -64,9 +81,25 @@ export function map_names_to_ids(
64
81
  return apis;
65
82
  }
66
83
 
84
+ export function resolve_config_root(
85
+ root: string,
86
+ current_location: string
87
+ ): string {
88
+ const root_url = new URL(root, current_location);
89
+ const current_url = new URL(current_location);
90
+ if (root_url.hostname !== current_url.hostname) {
91
+ return root;
92
+ }
93
+
94
+ root_url.protocol = current_url.protocol;
95
+ root_url.host = current_url.host;
96
+ return root_url.toString().replace(/\/$/, "");
97
+ }
98
+
67
99
  export async function resolve_config(
68
100
  this: Client,
69
- endpoint: string
101
+ endpoint: string,
102
+ strip_current_page = true
70
103
  ): Promise<Config | undefined> {
71
104
  const headers: Record<string, string> = this.options.token
72
105
  ? { Authorization: `Bearer ${this.options.token}` }
@@ -77,7 +110,7 @@ export async function resolve_config(
77
110
  window.gradio_config &&
78
111
  location.origin !== "http://localhost:9876"
79
112
  ) {
80
- if (window.gradio_config.current_page) {
113
+ if (strip_current_page && window.gradio_config.current_page) {
81
114
  endpoint = endpoint.substring(0, endpoint.lastIndexOf("/"));
82
115
  }
83
116
  if (
@@ -102,8 +135,13 @@ export async function resolve_config(
102
135
  current_page: window.gradio_config.current_page
103
136
  };
104
137
  }
105
- // @ts-ignore
106
- return { ...window.gradio_config } as Config;
138
+ // The page was rendered by this Gradio server, so a same-host root may
139
+ // contain an internal protocol or port supplied by a reverse proxy. Keep
140
+ // the configured path, but use the browser-visible origin for requests
141
+ // made by the client itself (queue, upload, reset, etc.).
142
+ const config = { ...window.gradio_config } as unknown as Config;
143
+ config.root = resolve_config_root(config.root, location.href);
144
+ return config;
107
145
  } else if (endpoint) {
108
146
  let config_url = join_urls(
109
147
  endpoint,
@@ -132,7 +170,14 @@ async function handleConfigResponse(
132
170
  authorized: boolean
133
171
  ): Promise<Config> {
134
172
  if (response?.status === 401 && !authorized) {
135
- const error_data = await response.json();
173
+ let error_data: any = null;
174
+ try {
175
+ error_data = await response.json();
176
+ } catch (e) {
177
+ // Unauthenticated requests to private Spaces receive a non-JSON 401
178
+ // page from the Hugging Face proxy rather than a Gradio auth payload.
179
+ throw new Error(PRIVATE_SPACE_MSG);
180
+ }
136
181
  const auth_message = error_data?.detail?.auth_message;
137
182
  throw new Error(auth_message || MISSING_CREDENTIALS_MSG);
138
183
  } else if (response?.status === 401 && authorized) {
@@ -151,7 +196,9 @@ async function handleConfigResponse(
151
196
  throw new Error(UNAUTHORIZED_MSG);
152
197
  }
153
198
 
154
- throw new Error(CONFIG_ERROR_MSG);
199
+ throw new Error(
200
+ `${CONFIG_ERROR_MSG}(received status ${response?.status} when fetching the app config)`
201
+ );
155
202
  }
156
203
 
157
204
  export async function resolve_cookies(this: Client): Promise<void> {
package/src/index.ts CHANGED
@@ -5,6 +5,18 @@ export { submit } from "./utils/submit";
5
5
  export { upload_files } from "./utils/upload_files";
6
6
  export { FileData, upload, prepare_files } from "./upload";
7
7
  export { handle_file } from "./helpers/data";
8
+ export {
9
+ clear_run_history,
10
+ consume_run_history_replay,
11
+ delete_run_history,
12
+ on_run_history_change,
13
+ read_run_history,
14
+ run_history_url,
15
+ stage_run_history_replay,
16
+ type RunHistoryScope,
17
+ type StoredRunComponent,
18
+ type StoredRun
19
+ } from "./utils/run_history";
8
20
 
9
21
  export type {
10
22
  SpaceStatus,
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  INVALID_URL_MSG,
3
3
  QUEUE_FULL_MSG,
4
- SPACE_METADATA_ERROR_MSG
4
+ SPACE_NOT_FOUND_MSG
5
5
  } from "../constants";
6
6
  import { beforeAll, afterEach, afterAll, it, expect, describe } from "vitest";
7
7
  import {
@@ -10,7 +10,8 @@ import {
10
10
  get_type,
11
11
  process_endpoint,
12
12
  join_urls,
13
- map_data_to_params
13
+ map_data_to_params,
14
+ transform_api_info
14
15
  } from "../helpers/api_info";
15
16
  import { initialise_server } from "./server";
16
17
  import { transformed_api_info } from "./test_data";
@@ -471,19 +472,13 @@ describe("process_endpoint", () => {
471
472
  expect(result).toEqual(expected);
472
473
  });
473
474
 
474
- it("should throw an error when fetching space metadata fails", async () => {
475
+ it("should throw a clear error when the space does not exist or is private", async () => {
475
476
  const app_reference = "hmb/bye_world";
476
477
  const token = "hf_token";
477
478
 
478
- try {
479
- await process_endpoint(app_reference, token);
480
- } catch (error) {
481
- if (error instanceof Error) {
482
- expect(error.message).toEqual(SPACE_METADATA_ERROR_MSG);
483
- } else {
484
- expect.fail("Error should not be unknown.");
485
- }
486
- }
479
+ await expect(process_endpoint(app_reference, token)).rejects.toThrow(
480
+ SPACE_NOT_FOUND_MSG(app_reference, 404)
481
+ );
487
482
  });
488
483
 
489
484
  it("should return the correct data when app_reference is a valid space domain", async () => {
@@ -699,3 +694,73 @@ describe("map_data_params", () => {
699
694
  );
700
695
  });
701
696
  });
697
+
698
+ describe("transform_api_info", () => {
699
+ it("defaults parameters and returns to empty arrays when an endpoint entry is malformed", () => {
700
+ const api_info = {
701
+ named_endpoints: {
702
+ // missing `parameters` and `returns`, as returned by some legacy
703
+ // or misbehaving apps (see issue #10945)
704
+ "/predict": {}
705
+ },
706
+ unnamed_endpoints: {}
707
+ } as any;
708
+ const config = {
709
+ dependencies: [
710
+ {
711
+ id: 0,
712
+ api_name: "predict",
713
+ inputs: [1],
714
+ outputs: [2],
715
+ types: { generator: false, cancel: false }
716
+ }
717
+ ],
718
+ components: [
719
+ { id: 1, type: "textbox", props: {} },
720
+ { id: 2, type: "textbox", props: {} }
721
+ ]
722
+ } as any;
723
+
724
+ const result = transform_api_info(api_info, config, { predict: 0 });
725
+
726
+ expect(result.named_endpoints["/predict"].parameters).toEqual([]);
727
+ expect(result.named_endpoints["/predict"].returns).toEqual([]);
728
+ });
729
+
730
+ it("keeps oauth_token, which submit() needs to decide where a token may be sent", () => {
731
+ const api_info = {
732
+ named_endpoints: {
733
+ "/report": { parameters: [], returns: [], oauth_token: "optional" },
734
+ "/calculator": { parameters: [], returns: [] }
735
+ },
736
+ unnamed_endpoints: {}
737
+ } as any;
738
+ const config = {
739
+ dependencies: [
740
+ {
741
+ id: 0,
742
+ api_name: "report",
743
+ inputs: [],
744
+ outputs: [],
745
+ types: { generator: false, cancel: false }
746
+ },
747
+ {
748
+ id: 1,
749
+ api_name: "calculator",
750
+ inputs: [],
751
+ outputs: [],
752
+ types: { generator: false, cancel: false }
753
+ }
754
+ ],
755
+ components: []
756
+ } as any;
757
+
758
+ const result = transform_api_info(api_info, config, {
759
+ report: 0,
760
+ calculator: 1
761
+ });
762
+
763
+ expect(result.named_endpoints["/report"].oauth_token).toBe("optional");
764
+ expect(result.named_endpoints["/calculator"].oauth_token).toBeUndefined();
765
+ });
766
+ });
@@ -97,6 +97,27 @@ describe("walk_and_store_blobs", () => {
97
97
  expect(parts[0].path).toEqual(["0"]);
98
98
  });
99
99
 
100
+ it("should preserve File instances (and their filenames)", async () => {
101
+ const file = new File(["test data"], "report.txt", {
102
+ type: "text/plain"
103
+ });
104
+ const parts = await walk_and_store_blobs([file]);
105
+
106
+ expect(parts).toHaveLength(1);
107
+ expect(parts[0].blob).toBe(file);
108
+ expect((parts[0].blob as File).name).toBe("report.txt");
109
+ expect((parts[0].blob as File).type).toBe("text/plain");
110
+ });
111
+
112
+ it("should preserve the MIME type of Blob instances", async () => {
113
+ const blob = new Blob(["test data"], { type: "image/png" });
114
+ const parts = await walk_and_store_blobs([blob]);
115
+
116
+ expect(parts).toHaveLength(1);
117
+ expect(parts[0].blob).toBe(blob);
118
+ expect(parts[0].blob && parts[0].blob.type).toBe("image/png");
119
+ });
120
+
100
121
  it("should handle deep structures", async () => {
101
122
  const image = new Blob([]);
102
123
  const parts = await walk_and_store_blobs({ a: { b: { data: { image } } } });
@@ -371,11 +392,13 @@ describe("handle_file", () => {
371
392
  );
372
393
 
373
394
  it.skipIf(IS_NODE)(
374
- "should handle a File object and return it as FileData",
395
+ "should handle a File object and return it unchanged, preserving its name and type",
375
396
  () => {
376
397
  const file = new File(["test image"], "test.png", { type: "image/png" });
377
- const result = handle_file(file) as FileData;
378
- expect(result).toBeInstanceOf(Blob);
398
+ const result = handle_file(file) as File;
399
+ expect(result).toBe(file);
400
+ expect(result.name).toBe("test.png");
401
+ expect(result.type).toBe("image/png");
379
402
  }
380
403
  );
381
404
 
@@ -15,7 +15,7 @@ import {
15
15
  response_api_info
16
16
  } from "./test_data";
17
17
  import { initialise_server } from "./server";
18
- import { SPACE_METADATA_ERROR_MSG } from "../constants";
18
+ import { SPACE_NOT_FOUND_MSG } from "../constants";
19
19
 
20
20
  const app_reference = "hmb/hello_world";
21
21
  const broken_app_reference = "hmb/bye_world";
@@ -87,12 +87,28 @@ describe("Client class", () => {
87
87
  });
88
88
  });
89
89
 
90
+ test("connecting successfully to a private running app with the deprecated hf_token option", async () => {
91
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
92
+ const app = await Client.connect("hmb/secret_world", {
93
+ hf_token: "hf_123"
94
+ });
95
+
96
+ expect(app.config).toEqual({
97
+ ...config_response,
98
+ root: "https://hmb-secret-world.hf.space"
99
+ });
100
+ expect(warn).toHaveBeenCalledWith(
101
+ expect.stringContaining("`hf_token` option has been renamed")
102
+ );
103
+ warn.mockRestore();
104
+ });
105
+
90
106
  test("unsuccessfully attempting to connect to a private running app", async () => {
91
107
  await expect(
92
108
  Client.connect("hmb/secret_world", {
93
109
  token: "hf_bad_token"
94
110
  })
95
- ).rejects.toThrowError(SPACE_METADATA_ERROR_MSG);
111
+ ).rejects.toThrowError(SPACE_NOT_FOUND_MSG("hmb/secret_world", 401));
96
112
  });
97
113
 
98
114
  test("viewing the api info of a running app", async () => {
@@ -139,7 +155,9 @@ describe("Client class", () => {
139
155
  test("creating a duplicate of a broken app", async () => {
140
156
  const duplicate = Client.duplicate(broken_app_reference);
141
157
 
142
- await expect(duplicate).rejects.toThrow(SPACE_METADATA_ERROR_MSG);
158
+ await expect(duplicate).rejects.toThrow(
159
+ SPACE_NOT_FOUND_MSG(broken_app_reference, 404)
160
+ );
143
161
  });
144
162
  });
145
163
 
@@ -3,7 +3,8 @@ import {
3
3
  get_jwt,
4
4
  determine_protocol,
5
5
  parse_and_set_cookies,
6
- resolve_config
6
+ resolve_config,
7
+ resolve_config_root
7
8
  } from "../helpers/init_helpers";
8
9
  import { initialise_server } from "./server";
9
10
  import { beforeAll, afterEach, afterAll, it, expect, describe } from "vitest";
@@ -21,6 +22,24 @@ afterEach(() => server.resetHandlers());
21
22
  afterAll(() => server.stop());
22
23
 
23
24
  describe("resolve_config", () => {
25
+ it("uses the public protocol and port for a same-host proxy root", () => {
26
+ expect(
27
+ resolve_config_root(
28
+ "http://machine.local:7862/gradio",
29
+ "https://machine.local:20443/gradio"
30
+ )
31
+ ).toBe("https://machine.local:20443/gradio");
32
+ });
33
+
34
+ it("keeps the configured origin for a remote root", () => {
35
+ expect(
36
+ resolve_config_root(
37
+ "https://remote.example/gradio",
38
+ "https://host.example/page"
39
+ )
40
+ ).toBe("https://remote.example/gradio");
41
+ });
42
+
24
43
  it("requests /config without a Content-Type header and with same-origin credentials, so the cross-origin embed fetch is not blocked by CORS", async () => {
25
44
  let captured_init: RequestInit | undefined;
26
45
  const fake_client = {
@@ -43,6 +62,53 @@ describe("resolve_config", () => {
43
62
  expect(header_names).not.toContain("content-type");
44
63
  expect(captured_init?.credentials).toBe("same-origin");
45
64
  });
65
+
66
+ const in_browser = typeof window !== "undefined";
67
+
68
+ it.skipIf(!in_browser)(
69
+ "uses the browser origin for a same-host config root behind a proxy",
70
+ async () => {
71
+ const page = new URL(window.location.href);
72
+ const internal_root = `${page.protocol}//${page.hostname}:7862/gradio`;
73
+ window.gradio_config = {
74
+ ...config_response,
75
+ root: internal_root
76
+ };
77
+ const fake_client = {
78
+ options: {},
79
+ deep_link: null
80
+ } as unknown as Client;
81
+
82
+ try {
83
+ const config = await resolve_config.call(fake_client, internal_root);
84
+ expect(config?.root).toBe(`${page.origin}/gradio`);
85
+ } finally {
86
+ delete (window as Partial<Window>).gradio_config;
87
+ }
88
+ }
89
+ );
90
+
91
+ it.skipIf(!in_browser)(
92
+ "keeps a remote config root when the page and backend hostnames differ",
93
+ async () => {
94
+ const remote_root = "https://remote.example/gradio";
95
+ window.gradio_config = {
96
+ ...config_response,
97
+ root: remote_root
98
+ };
99
+ const fake_client = {
100
+ options: {},
101
+ deep_link: null
102
+ } as unknown as Client;
103
+
104
+ try {
105
+ const config = await resolve_config.call(fake_client, remote_root);
106
+ expect(config?.root).toBe(remote_root);
107
+ } finally {
108
+ delete (window as Partial<Window>).gradio_config;
109
+ }
110
+ }
111
+ );
46
112
  });
47
113
 
48
114
  describe("resolve_root", () => {
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { Client } from "../client";
3
+ import type { Config } from "../types";
4
+
5
+ describe("Client.refresh", () => {
6
+ test("refreshes from the app root without stripping the current page again", async () => {
7
+ const client = new Client("https://example.test/app/page");
8
+ client.config = {
9
+ root: "https://example.test/app",
10
+ dependencies: []
11
+ } as unknown as Config;
12
+
13
+ const refreshed_config = {
14
+ root: "https://example.test/app",
15
+ api_prefix: "/gradio_api",
16
+ dependencies: [{ id: 4, api_name: "generate" }]
17
+ } as unknown as Config;
18
+ const resolve_config = vi.fn().mockResolvedValue(refreshed_config);
19
+ Reflect.set(client, "resolve_config", resolve_config);
20
+ client.view_api = vi.fn().mockResolvedValue({});
21
+ client.get_url_config = vi.fn().mockReturnValue(refreshed_config);
22
+
23
+ await client.refresh();
24
+
25
+ expect(resolve_config).toHaveBeenCalledWith(
26
+ "https://example.test/app",
27
+ false
28
+ );
29
+ expect(client.api_map).toEqual({ generate: 4 });
30
+ });
31
+ });