holon-sh 0.0.1-dev.6 → 0.0.1-dev.8
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.
- package/README.md +36 -87
- package/dist/context.d.ts +55 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +9 -0
- package/dist/context.js.map +1 -0
- package/dist/ext/files.client.d.ts +50 -0
- package/dist/ext/files.client.d.ts.map +1 -0
- package/dist/ext/files.client.js +359 -0
- package/dist/ext/files.client.js.map +1 -0
- package/dist/ext/files.d.ts +21 -0
- package/dist/ext/files.d.ts.map +1 -0
- package/dist/ext/files.js +3 -0
- package/dist/ext/files.js.map +1 -0
- package/dist/ext/platform.browser.d.ts +4 -0
- package/dist/ext/platform.browser.d.ts.map +1 -0
- package/dist/ext/platform.browser.js +15 -0
- package/dist/ext/platform.browser.js.map +1 -0
- package/dist/ext/platform.node.d.ts +4 -0
- package/dist/ext/platform.node.d.ts.map +1 -0
- package/dist/ext/platform.node.js +45 -0
- package/dist/ext/platform.node.js.map +1 -0
- package/dist/protocol.d.ts +76 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +32 -0
- package/dist/protocol.js.map +1 -0
- package/dist/runtime.d.ts +67 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +276 -0
- package/dist/runtime.js.map +1 -0
- package/package.json +42 -28
- package/src/context.ts +29 -0
- package/src/ext/files.client.ts +329 -0
- package/src/ext/files.ts +16 -0
- package/src/ext/platform.browser.ts +15 -0
- package/src/ext/platform.node.ts +43 -0
- package/{protocol.d.ts → src/protocol.ts} +38 -14
- package/src/runtime.ts +315 -0
- package/generated/holon-generated.json +0 -4
- package/generated/index.d.ts +0 -12
- package/generated/index.js +0 -5
- package/runtime.d.ts +0 -49
- package/runtime.mjs +0 -191
- package/validate.mjs +0 -10
- package/view.d.ts +0 -47
- package/view.js +0 -30
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# Holon TypeScript SDK
|
|
2
2
|
|
|
3
3
|
[](https://github.com/holon-sh/sdk-ts/actions/workflows/ci.yml)
|
|
4
|
+
[](https://holon-labs.dev/docs/)
|
|
4
5
|
[](https://nodejs.org/en/download)
|
|
5
6
|
[](https://github.com/holon-sh/sdk-ts/blob/main/LICENSE)
|
|
7
|
+
[](https://discord.gg/jXExyBdy)
|
|
6
8
|
|
|
7
9
|
This repository contains the Node.js runtime for [`holon`](https://github.com/holon-sh/cli).
|
|
8
10
|
|
|
@@ -10,114 +12,61 @@ This repository contains the Node.js runtime for [`holon`](https://github.com/ho
|
|
|
10
12
|
|
|
11
13
|
```sh
|
|
12
14
|
npm install holon-sh@dev
|
|
13
|
-
npm install -D holon-types
|
|
15
|
+
npm install -D holon-types typescript
|
|
14
16
|
```
|
|
15
17
|
|
|
16
18
|
`holon-types` is the `holon` CLI. `holon types check` brings its own TypeScript compiler and uses the project's `typescript` when one is installed.
|
|
17
19
|
|
|
18
|
-
##
|
|
19
|
-
|
|
20
|
-
The Holon TypeScript SDK allows generation of type-safe MCP clients. See [Example](https://github.com/holon-sh/cli#example) for an explanation, or [Dependencies](https://github.com/holon-sh/cli#dependencies) to understand dependency resolution.
|
|
21
|
-
|
|
22
|
-
### Type-checking MCP invocations
|
|
20
|
+
## Create an app
|
|
23
21
|
|
|
24
22
|
```sh
|
|
25
|
-
npx holon
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
<details open>
|
|
29
|
-
<summary><h3>Call a tool</h3></summary>
|
|
30
|
-
|
|
31
|
-
```ts
|
|
32
|
-
import { apps } from "holon-sh";
|
|
33
|
-
|
|
34
|
-
export async function greet(name: string): Promise<string> {
|
|
35
|
-
const greeting = await apps.bar.tools.greet({ name }, { timeout: 5000 });
|
|
36
|
-
return greeting.result;
|
|
37
|
-
}
|
|
23
|
+
npx create-holon-app foo
|
|
38
24
|
```
|
|
39
25
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
</details>
|
|
43
|
-
|
|
44
|
-
<details open>
|
|
45
|
-
<summary><h3>Handle errors</h3></summary>
|
|
46
|
-
|
|
47
|
-
```ts
|
|
48
|
-
import { ToolCallError, apps } from "holon-sh";
|
|
26
|
+
## Invoking MCP Servers
|
|
49
27
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
28
|
+
Declare dependencies under `sh.holon.requires` in a [`server.json`](https://modelcontextprotocol.io/registry/quickstart#step-4-create-server-json) beside the server, here the public [Hugging Face](https://huggingface.co/docs/hub/en/hf-mcp-server) MCP server:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"name": "com.example/foo",
|
|
33
|
+
"version": "0.1.0",
|
|
34
|
+
"description": "Example Foo",
|
|
35
|
+
"_meta": {
|
|
36
|
+
"io.modelcontextprotocol.registry/publisher-provided": {
|
|
37
|
+
"sh.holon": {
|
|
38
|
+
"requires": {
|
|
39
|
+
"huggingface": { "url": "https://huggingface.co/mcp" }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
55
44
|
}
|
|
56
45
|
```
|
|
57
46
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
<details open>
|
|
61
|
-
<summary><h3>Read the whole result</h3></summary>
|
|
62
|
-
|
|
63
|
-
`callTool` returns the full MCP result instead of throwing:
|
|
47
|
+
### Type-checking MCP invocations
|
|
64
48
|
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
if (result.resultType === "input_required") {
|
|
68
|
-
// call again with inputResponses and requestState
|
|
69
|
-
} else if (result.isError) {
|
|
70
|
-
// inspect result.content
|
|
71
|
-
} else {
|
|
72
|
-
result.structuredContent; // typed as Outputs["bar"]["greet"]
|
|
73
|
-
}
|
|
49
|
+
```sh
|
|
50
|
+
npx holon types check
|
|
74
51
|
```
|
|
75
52
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
<details open>
|
|
79
|
-
<summary><h3>Answer a server's question</h3></summary>
|
|
80
|
-
|
|
81
|
-
A tool can stop and ask for input through an [MCP elicitation](https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation). Pass `elicit` to answer in place, and the call returns its usual typed output:
|
|
53
|
+
A wrong tool name or argument is a type error:
|
|
82
54
|
|
|
83
55
|
```ts
|
|
84
|
-
|
|
85
|
-
elicit: async ({ message }) => ({ action: "accept", content: await ask(message) }),
|
|
86
|
-
});
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
Without `elicit`, the question arrives on `ToolCallError.result`, or as an `input_required` result from `callTool`. To answer later, for example in another request, call again with `inputResponses` and `requestState`.
|
|
56
|
+
import { apps } from "#holon-sh";
|
|
90
57
|
|
|
91
|
-
|
|
58
|
+
await apps.huggingface.tools.hub_repo_search({ query: "llama" });
|
|
92
59
|
|
|
93
|
-
|
|
94
|
-
|
|
60
|
+
await apps.huggingface.tools.hub_repo_search({ limit: "3" });
|
|
61
|
+
// error TS2322: Type 'string' is not assignable to type 'number'.
|
|
95
62
|
|
|
96
|
-
|
|
63
|
+
await apps.huggingface.tools.hub_repo_details({});
|
|
64
|
+
// error TS2741: Property '"repo_ids"' is missing in type '{}' but required in type '{ config?: string | undefined; limit?: number | undefined; offset?: number | undefined; operations?: ("dataset_preview" | "dataset_structure" | "overview")[] | undefined; repo_ids: string[]; repo_type?: "dataset" | ... 2 more ... | undefined; split?: string | undefined; }'.
|
|
97
65
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const controller = new AbortController();
|
|
102
|
-
await apps.orders.resources["orders://summary"].watch(value => console.log(value.total), {
|
|
103
|
-
signal: controller.signal,
|
|
104
|
-
});
|
|
66
|
+
await apps.huggingface.tools.hub_search({ query: "llama" });
|
|
67
|
+
// error TS2339: Property 'hub_search' does not exist on type '{ readonly hf_fs: (input: { operations: { args: string[]; cmd: "attach" | "cat" | "find" | "ls" | "search" | "stat"; }[]; }, options?: ToolCallOptions | undefined) => Promise<{ results: ({ ...; } | { ...; })[]; truncated?: boolean | undefined; truncation_reason?: "output_budget" | undefined; }>; readonly hf_whoami: ...'.
|
|
105
68
|
```
|
|
106
69
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
</details>
|
|
110
|
-
|
|
111
|
-
## Serve an [MCP App](https://modelcontextprotocol.io/extensions/apps/overview) view
|
|
112
|
-
|
|
113
|
-
```ts
|
|
114
|
-
import { appView } from "holon-sh/view";
|
|
115
|
-
|
|
116
|
-
server.registerResource(
|
|
117
|
-
...appView("Foo", "ui://foo/index.html", new URL("./dist/index.html", import.meta.url), {
|
|
118
|
-
csp: { connectDomains: ["https://bar.example.com"] },
|
|
119
|
-
}),
|
|
120
|
-
);
|
|
121
|
-
```
|
|
70
|
+
## Community
|
|
122
71
|
|
|
123
|
-
|
|
72
|
+
Ask questions and share what you're building on the [Holon Discord](https://discord.gg/jXExyBdy).
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Standard OpenID Connect address claims. */
|
|
2
|
+
export interface AddressClaims {
|
|
3
|
+
formatted?: string;
|
|
4
|
+
street_address?: string;
|
|
5
|
+
locality?: string;
|
|
6
|
+
region?: string;
|
|
7
|
+
postal_code?: string;
|
|
8
|
+
country?: string;
|
|
9
|
+
[claim: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
/** Standard OpenID Connect claims. Holon drops one whose value has the wrong type; other claims pass through as JSON. */
|
|
12
|
+
export interface IdentityClaims {
|
|
13
|
+
name?: string;
|
|
14
|
+
given_name?: string;
|
|
15
|
+
family_name?: string;
|
|
16
|
+
middle_name?: string;
|
|
17
|
+
nickname?: string;
|
|
18
|
+
preferred_username?: string;
|
|
19
|
+
profile?: string;
|
|
20
|
+
picture?: string;
|
|
21
|
+
website?: string;
|
|
22
|
+
email?: string;
|
|
23
|
+
email_verified?: boolean;
|
|
24
|
+
gender?: string;
|
|
25
|
+
birthdate?: string;
|
|
26
|
+
zoneinfo?: string;
|
|
27
|
+
locale?: string;
|
|
28
|
+
phone_number?: string;
|
|
29
|
+
phone_number_verified?: boolean;
|
|
30
|
+
address?: AddressClaims;
|
|
31
|
+
updated_at?: number;
|
|
32
|
+
[claim: string]: unknown;
|
|
33
|
+
}
|
|
34
|
+
/** The user a request acts for, as Holon admitted it. */
|
|
35
|
+
export interface Principal {
|
|
36
|
+
issuer: string;
|
|
37
|
+
subject: string;
|
|
38
|
+
clientId: string;
|
|
39
|
+
scopes: string[];
|
|
40
|
+
claims: IdentityClaims;
|
|
41
|
+
actor?: Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
/** The caller of the current MCP request. principal is null for anonymous requests. */
|
|
44
|
+
export interface RequestContext {
|
|
45
|
+
principal: Principal | null;
|
|
46
|
+
}
|
|
47
|
+
/** Read the caller from the context an MCP server receives: a tool callback's ctx.http, or the McpRequestContext of a createMcpHandler factory. */
|
|
48
|
+
export declare function getContext(context?: {
|
|
49
|
+
authInfo?: {
|
|
50
|
+
clientId: string;
|
|
51
|
+
scopes: string[];
|
|
52
|
+
extra?: Record<string, unknown>;
|
|
53
|
+
};
|
|
54
|
+
}): RequestContext;
|
|
55
|
+
//# sourceMappingURL=context.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA,8CAA8C;AAC9C,MAAM,WAAW,aAAa;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IACxH,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AACD,yHAAyH;AACzH,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClG,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAClG,cAAc,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAClG,YAAY,CAAC,EAAE,MAAM,CAAC;IAAC,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,aAAa,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IACrG,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AACD,yDAAyD;AACzD,MAAM,WAAW,SAAS;IAAG,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,EAAE,cAAc,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE;AAC3J,uFAAuF;AACvF,MAAM,WAAW,cAAc;IAAG,SAAS,EAAE,SAAS,GAAG,IAAI,CAAA;CAAE;AAM/D,mJAAmJ;AACnJ,wBAAgB,UAAU,CAAC,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,EAAE,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAA;CAAO,GAAG,cAAc,CAK/I"}
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Read the caller from the context an MCP server receives: a tool callback's ctx.http, or the McpRequestContext of a createMcpHandler factory. */
|
|
2
|
+
export function getContext(context = {}) {
|
|
3
|
+
const { authInfo } = context;
|
|
4
|
+
const { iss, sub, claims, act } = (authInfo?.extra ?? {});
|
|
5
|
+
if (!authInfo || typeof iss !== "string" || typeof sub !== "string")
|
|
6
|
+
return { principal: null };
|
|
7
|
+
return { principal: { issuer: iss, subject: sub, clientId: authInfo.clientId, scopes: authInfo.scopes, claims: claims ?? {}, ...(act && { actor: act }) } };
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=context.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAsBA,mJAAmJ;AACnJ,MAAM,UAAU,UAAU,CAAC,OAAO,GAA2F,EAAE;IAC7H,MAAM,EAAE,QAAQ,EAAE,GAAG,OAAO,CAAC;IAC7B,MAAM,EAAE,GAAG,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,CAAC,QAAQ,EAAE,KAAK,IAAI,EAAE,CAAa,CAAC;IACtE,IAAI,CAAC,QAAQ,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,OAAO,GAAG,KAAK,QAAQ;QAAE,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;IAChG,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,IAAI,EAAE,EAAE,GAAG,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC;AAC9J,CAAC"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Client, Transport } from "@modelcontextprotocol/client";
|
|
2
|
+
import { type Download } from "./files.js";
|
|
3
|
+
type Json = Record<string, unknown>;
|
|
4
|
+
export interface FileConnection {
|
|
5
|
+
client: Pick<Client, "getServerCapabilities">;
|
|
6
|
+
resolved: {
|
|
7
|
+
endpoint: URL | string;
|
|
8
|
+
fileOrigins?: string[];
|
|
9
|
+
installationUID?: string;
|
|
10
|
+
};
|
|
11
|
+
authorization?: string;
|
|
12
|
+
}
|
|
13
|
+
interface FileInput {
|
|
14
|
+
id: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
mimeType: string;
|
|
17
|
+
size: number;
|
|
18
|
+
digest?: {
|
|
19
|
+
algorithm: "sha-256";
|
|
20
|
+
value: string;
|
|
21
|
+
};
|
|
22
|
+
data?: string;
|
|
23
|
+
}
|
|
24
|
+
interface Attachment {
|
|
25
|
+
blob: Blob;
|
|
26
|
+
input: FileInput;
|
|
27
|
+
}
|
|
28
|
+
export type Files = Map<string, Attachment>;
|
|
29
|
+
export declare function settings({ client }: FileConnection): Json | undefined;
|
|
30
|
+
export declare function isUploadRequest(error: unknown): error is {
|
|
31
|
+
data: Json;
|
|
32
|
+
};
|
|
33
|
+
export declare function acceptUploadRequests<T extends Transport>(transport: T): T;
|
|
34
|
+
export declare function prepare(args: Json | undefined, { label, connection, schema }: {
|
|
35
|
+
label: string;
|
|
36
|
+
connection: FileConnection | undefined;
|
|
37
|
+
schema: () => Promise<unknown>;
|
|
38
|
+
}): Promise<{
|
|
39
|
+
arguments: Json | undefined;
|
|
40
|
+
files: Files;
|
|
41
|
+
}>;
|
|
42
|
+
export declare function upload(request: Json, files: Files, connection: FileConnection, signal: AbortSignal | undefined, label: string): Promise<string>;
|
|
43
|
+
export declare function downloads<R extends {
|
|
44
|
+
resultType?: string;
|
|
45
|
+
_meta?: object;
|
|
46
|
+
}>(result: R, connection: FileConnection, enabled: boolean): R & {
|
|
47
|
+
downloads?: Download[];
|
|
48
|
+
};
|
|
49
|
+
export {};
|
|
50
|
+
//# sourceMappingURL=files.client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"files.client.d.ts","sourceRoot":"","sources":["../../src/ext/files.client.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAoC,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAExG,OAAO,EAAa,KAAK,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEtD,KAAK,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEpC,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAC;IAC9C,QAAQ,EAAE;QAAE,QAAQ,EAAE,GAAG,GAAG,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,eAAe,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACvF,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,UAAU,SAAS;IAAG,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE;QAAE,SAAS,EAAE,SAAS,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE;AAClJ,UAAU,UAAU;IAAG,IAAI,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,SAAS,CAAA;CAAE;AACrD,MAAM,MAAM,KAAK,GAAG,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;AAY5C,wBAAgB,QAAQ,CAAC,EAAE,MAAM,EAAE,EAAE,cAAc,GAAG,IAAI,GAAG,SAAS,CAGrE;AAID,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI;IAAE,IAAI,EAAE,IAAI,CAAA;CAAE,CAGvE;AAKD,wBAAgB,oBAAoB,CAAC,CAAC,SAAS,SAAS,EAAE,SAAS,EAAE,CAAC,GAAG,CAAC,CAWzE;AA4CD,wBAAsB,OAAO,CAAC,IAAI,EAAE,IAAI,GAAG,SAAS,EAAE,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,cAAc,GAAG,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAA;CAAE,GAAG,OAAO,CAAC;IAAE,SAAS,EAAE,IAAI,GAAG,SAAS,CAAC;IAAC,KAAK,EAAE,KAAK,CAAA;CAAE,CAAC,CA4BtO;AAqID,wBAAsB,MAAM,CAAC,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,EAAE,WAAW,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAwBrJ;AAwCD,wBAAgB,SAAS,CAAC,CAAC,SAAS;IAAE,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,MAAM,EAAE,CAAC,EAAE,UAAU,EAAE,cAAc,EAAE,OAAO,EAAE,OAAO,GAAG,CAAC,GAAG;IAAE,SAAS,CAAC,EAAE,QAAQ,EAAE,CAAA;CAAE,CAKpK"}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { base64, fetchGlobal, sha256 } from "#platform";
|
|
2
|
+
import { EXTENSION } from "./files.js";
|
|
3
|
+
const list = (value) => Array.isArray(value) ? value : [];
|
|
4
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
const isPlain = (value) => isObject(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
6
|
+
const present = (value) => isObject(value) ? Object.fromEntries(Object.entries(value).filter(([, member]) => member !== null)) : undefined;
|
|
7
|
+
const quoted = (name) => name === undefined ? "a file" : JSON.stringify(name);
|
|
8
|
+
function refuse(reason) {
|
|
9
|
+
throw new Error(reason);
|
|
10
|
+
}
|
|
11
|
+
export function settings({ client }) {
|
|
12
|
+
const advertised = client.getServerCapabilities()?.extensions?.[EXTENSION];
|
|
13
|
+
return isObject(advertised) && advertised.revision == null ? advertised : undefined;
|
|
14
|
+
}
|
|
15
|
+
const uploadRequests = new WeakSet();
|
|
16
|
+
export function isUploadRequest(error) {
|
|
17
|
+
const data = error?.data;
|
|
18
|
+
return isObject(data) && uploadRequests.has(data);
|
|
19
|
+
}
|
|
20
|
+
// The MCP client rejects a result type it does not know and drops the result, and has no hook for extension result
|
|
21
|
+
// types yet (typescript-sdk#2782). The transport hands an upload request to it as a JSON-RPC error instead, whose data is
|
|
22
|
+
// the result: the client keeps an error's data as received. Every other message passes through unchanged.
|
|
23
|
+
export function acceptUploadRequests(transport) {
|
|
24
|
+
let handler;
|
|
25
|
+
const receive = (message, extra) => {
|
|
26
|
+
if ("result" in message && message.result?.resultType === "upload_required") {
|
|
27
|
+
uploadRequests.add(message.result);
|
|
28
|
+
message = { jsonrpc: "2.0", id: message.id, error: { code: -32603, message: "Unsupported result type 'upload_required'", data: message.result } };
|
|
29
|
+
}
|
|
30
|
+
handler?.(message, extra);
|
|
31
|
+
};
|
|
32
|
+
Object.defineProperty(transport, "onmessage", { configurable: true, get: () => handler && receive, set: (value) => { handler = value; } });
|
|
33
|
+
return transport;
|
|
34
|
+
}
|
|
35
|
+
function contains(value) {
|
|
36
|
+
if (value instanceof Blob)
|
|
37
|
+
return true;
|
|
38
|
+
if (Array.isArray(value))
|
|
39
|
+
return value.some(contains);
|
|
40
|
+
return isPlain(value) && Object.values(value).some(contains);
|
|
41
|
+
}
|
|
42
|
+
const resolve = (root, ref) => ref.slice(1).split("/").slice(1)
|
|
43
|
+
.reduce((node, token) => node === null || typeof node !== "object" ? undefined : node[decodeURIComponent(token).replaceAll("~1", "/").replaceAll("~0", "~")], root);
|
|
44
|
+
function expand(schemas, root, seen = new Set()) {
|
|
45
|
+
return schemas.flatMap(schema => {
|
|
46
|
+
if (!isObject(schema) || seen.has(schema))
|
|
47
|
+
return [];
|
|
48
|
+
seen.add(schema);
|
|
49
|
+
const target = typeof schema.$ref === "string" && schema.$ref.startsWith("#") ? resolve(root, schema.$ref) : undefined;
|
|
50
|
+
return [schema, ...expand([target, ...list(schema.anyOf), ...list(schema.oneOf), ...list(schema.allOf)], root, seen)];
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function replace(value, schemas, root, path, found, label) {
|
|
54
|
+
if (value instanceof Blob) {
|
|
55
|
+
const schema = expand(schemas, root).find(schema => "x-mcp-file" in schema);
|
|
56
|
+
if (!schema)
|
|
57
|
+
throw new Error(`${label}: the Blob at ${path || "/"} is not a file argument of the tool; the tool was not called.`);
|
|
58
|
+
const input = {};
|
|
59
|
+
found.push({ blob: value, schema, input });
|
|
60
|
+
return input;
|
|
61
|
+
}
|
|
62
|
+
if (Array.isArray(value)) {
|
|
63
|
+
const all = expand(schemas, root);
|
|
64
|
+
return value.map((item, index) => replace(item, all.map(schema => index < list(schema.prefixItems).length ? list(schema.prefixItems)[index] : schema.items), root, `${path}/${index}`, found, label));
|
|
65
|
+
}
|
|
66
|
+
if (!isPlain(value))
|
|
67
|
+
return value;
|
|
68
|
+
const all = expand(schemas, root);
|
|
69
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, replace(item, all.map(schema => isObject(schema.properties) ? schema.properties[key] : undefined), root, `${path}/${key}`, found, label)]));
|
|
70
|
+
}
|
|
71
|
+
function baseName(name) {
|
|
72
|
+
const base = typeof name === "string" ? name.split(/[/\\]/).pop() : "";
|
|
73
|
+
return base && base !== "." && base !== ".." ? base : undefined;
|
|
74
|
+
}
|
|
75
|
+
export async function prepare(args, { label, connection, schema }) {
|
|
76
|
+
const files = new Map();
|
|
77
|
+
if (!contains(args))
|
|
78
|
+
return { arguments: args, files };
|
|
79
|
+
if (!connection)
|
|
80
|
+
throw new Error(`${label}: file arguments need an endpoint binding; the tool was not called.`);
|
|
81
|
+
const server = settings(connection);
|
|
82
|
+
if (!server)
|
|
83
|
+
throw new Error(`${label}: the server does not accept files; the tool was not called.`);
|
|
84
|
+
const root = await schema();
|
|
85
|
+
const found = [];
|
|
86
|
+
const replaced = replace(args, [root], root, "", found, label);
|
|
87
|
+
await Promise.all(found.map(async ({ blob, schema, input }) => {
|
|
88
|
+
const descriptor = present(schema["x-mcp-file"]) ?? {};
|
|
89
|
+
const name = baseName(blob.name);
|
|
90
|
+
if (blob.size > descriptor.maxSize)
|
|
91
|
+
throw new Error(`${label}: ${quoted(name)} is ${blob.size} bytes, over the tool's limit of ${descriptor.maxSize}; the tool was not called.`);
|
|
92
|
+
Object.assign(input, { id: crypto.randomUUID(), ...(name !== undefined && { name }), mimeType: blob.type || "application/octet-stream", size: blob.size });
|
|
93
|
+
if (list(schema.required).includes("digest"))
|
|
94
|
+
input.digest = { algorithm: "sha-256", value: await sha256(blob) };
|
|
95
|
+
files.set(input.id, { blob, input });
|
|
96
|
+
}));
|
|
97
|
+
let budget = Number.isSafeInteger(server.maxInlineBytes) ? server.maxInlineBytes : 0;
|
|
98
|
+
for (const { blob, input } of [...files.values()].sort((a, b) => a.blob.size - b.blob.size)) {
|
|
99
|
+
if (blob.size === 0)
|
|
100
|
+
continue;
|
|
101
|
+
if (blob.size <= budget) {
|
|
102
|
+
budget -= blob.size;
|
|
103
|
+
input.data = await base64(blob);
|
|
104
|
+
}
|
|
105
|
+
else if (!isObject(server.upload)) {
|
|
106
|
+
throw new Error(`${label}: the server cannot receive ${quoted(input.name)} (${blob.size} bytes): it takes ${budget} more bytes inline and no uploads; the tool was not called.`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { arguments: replaced, files };
|
|
110
|
+
}
|
|
111
|
+
const UPLOAD = new Set(["fileId", "url", "path", "method", "form", "headers", "credentials", "expiresAt", "_meta"]);
|
|
112
|
+
const DOWNLOAD = new Set(["name", "mimeType", "size", "digest", "url", "path", "headers", "credentials", "expiresAt", "_meta"]);
|
|
113
|
+
const TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
114
|
+
const FIELD_VALUE = /^(?:[\x21-\x7e\x80-\xff](?:[\t\x20-\x7e\x80-\xff]*[\x21-\x7e\x80-\xff])?)?$/;
|
|
115
|
+
// https://fetch.spec.whatwg.org/#forbidden-request-header, plus Authorization.
|
|
116
|
+
const FORBIDDEN = new Set(["accept-charset", "accept-encoding", "access-control-request-headers", "access-control-request-method", "authorization", "connection", "content-length", "cookie", "cookie2", "date", "dnt", "expect", "host", "keep-alive", "origin", "referer", "set-cookie", "te", "trailer", "transfer-encoding", "upgrade", "via"]);
|
|
117
|
+
const OVERRIDES = new Set(["x-http-method-override", "x-http-method", "x-method-override"]);
|
|
118
|
+
const ROUTING = new Set(["forwarded", "x-original-url", "x-rewrite-url", ...OVERRIDES]);
|
|
119
|
+
const FORM_HEADERS = new Set(["content-type", "content-encoding", "content-range", "content-digest", "repr-digest"]);
|
|
120
|
+
const FORM_NAME = /^[^\p{Cc}"\\]+$/u;
|
|
121
|
+
const PATH = /^(?:\/(?!\.\.?(?:\/|$))[A-Za-z0-9\-_~.]+)+$/;
|
|
122
|
+
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/;
|
|
123
|
+
const loopback = (host) => host === "localhost" || host.endsWith(".localhost") || /^127\.\d+\.\d+\.\d+$/.test(host) || host === "[::1]";
|
|
124
|
+
const expired = (target) => target.expiresAt !== undefined && Date.parse(target.expiresAt) <= Date.now();
|
|
125
|
+
function parse(url) {
|
|
126
|
+
try {
|
|
127
|
+
return new URL(url);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function check(raw, kind, { resolved }) {
|
|
134
|
+
const target = present(raw);
|
|
135
|
+
if (!target)
|
|
136
|
+
refuse("not an object");
|
|
137
|
+
const unknown = Object.keys(target).find(key => !(kind === "upload" ? UPLOAD : DOWNLOAD).has(key));
|
|
138
|
+
if (unknown !== undefined)
|
|
139
|
+
refuse(`unknown member ${JSON.stringify(unknown)}`);
|
|
140
|
+
if (typeof target.url === "string" === (typeof target.path === "string") || [target.url, target.path].some(value => value !== undefined && typeof value !== "string"))
|
|
141
|
+
refuse("needs exactly one of url and path");
|
|
142
|
+
if (target.credentials !== "omit" && target.credentials !== "include")
|
|
143
|
+
refuse("credentials must be omit or include");
|
|
144
|
+
const { expiresAt } = target;
|
|
145
|
+
if (expiresAt !== undefined && !(typeof expiresAt === "string" && TIMESTAMP.test(expiresAt) && !Number.isNaN(Date.parse(expiresAt))))
|
|
146
|
+
refuse("expiresAt is not an RFC 3339 timestamp");
|
|
147
|
+
if (target.headers !== undefined && !(isObject(target.headers) && Object.values(target.headers).every(value => typeof value === "string")))
|
|
148
|
+
refuse("headers must map names to strings");
|
|
149
|
+
const headers = (target.headers ?? {});
|
|
150
|
+
let form;
|
|
151
|
+
if (kind === "upload") {
|
|
152
|
+
if (typeof target.fileId !== "string")
|
|
153
|
+
refuse("fileId is not a string");
|
|
154
|
+
if (target.method !== undefined && target.method !== "PUT" && target.method !== "POST")
|
|
155
|
+
refuse("method must be PUT or POST");
|
|
156
|
+
if (target.form !== undefined) {
|
|
157
|
+
const raw = present(target.form);
|
|
158
|
+
if (target.method !== "POST")
|
|
159
|
+
refuse("a form target must use POST");
|
|
160
|
+
if (!raw || Object.keys(raw).some(key => key !== "fields" && key !== "fileField") || !Array.isArray(raw.fields) || typeof raw.fileField !== "string")
|
|
161
|
+
refuse("form is malformed");
|
|
162
|
+
const fields = raw.fields.map(present);
|
|
163
|
+
if (!FORM_NAME.test(raw.fileField))
|
|
164
|
+
refuse("form fileField is not allowed");
|
|
165
|
+
for (const field of fields) {
|
|
166
|
+
if (!field || Object.keys(field).some(key => key !== "name" && key !== "value") || typeof field.name !== "string" || typeof field.value !== "string")
|
|
167
|
+
refuse("a form field is malformed");
|
|
168
|
+
if (!FORM_NAME.test(field.name) || field.name === raw.fileField || /[\r\n\0]/.test(field.value))
|
|
169
|
+
refuse("a form field is not allowed");
|
|
170
|
+
}
|
|
171
|
+
form = { fileField: raw.fileField, fields: fields };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
if (target.name !== undefined && typeof target.name !== "string")
|
|
176
|
+
refuse("name is not a string");
|
|
177
|
+
if (typeof target.mimeType !== "string")
|
|
178
|
+
refuse("mimeType is not a string");
|
|
179
|
+
if (!Number.isSafeInteger(target.size) || target.size < 0)
|
|
180
|
+
refuse("size is not a non-negative integer");
|
|
181
|
+
const digest = target.digest === undefined ? undefined : present(target.digest);
|
|
182
|
+
if (target.digest !== undefined && (!digest || Object.keys(digest).some(key => key !== "algorithm" && key !== "value") || digest.algorithm !== "sha-256" || typeof digest.value !== "string"))
|
|
183
|
+
refuse("digest is malformed");
|
|
184
|
+
}
|
|
185
|
+
const include = target.credentials === "include";
|
|
186
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
187
|
+
const lower = name.toLowerCase();
|
|
188
|
+
if (!TOKEN.test(name) || !FIELD_VALUE.test(value))
|
|
189
|
+
refuse("a header is not valid HTTP");
|
|
190
|
+
if (FORBIDDEN.has(lower) || lower.startsWith("proxy-") || lower.startsWith("sec-")
|
|
191
|
+
|| OVERRIDES.has(lower) && value.split(",").some(method => ["connect", "trace", "track"].includes(method.trim().toLowerCase())))
|
|
192
|
+
refuse(`header ${name} is not allowed`);
|
|
193
|
+
if ((target.path !== undefined || include) && (ROUTING.has(lower) || lower.startsWith("x-forwarded-")))
|
|
194
|
+
refuse(`header ${name} is not allowed with this target`);
|
|
195
|
+
if (form && FORM_HEADERS.has(lower))
|
|
196
|
+
refuse(`header ${name} is not allowed with a form`);
|
|
197
|
+
}
|
|
198
|
+
const endpoint = new URL(resolved.endpoint);
|
|
199
|
+
const origins = resolved.fileOrigins && new Set(resolved.fileOrigins.map(origin => new URL(origin).origin));
|
|
200
|
+
let url;
|
|
201
|
+
let allowlisted = false;
|
|
202
|
+
if (typeof target.url === "string") {
|
|
203
|
+
const parsed = parse(target.url);
|
|
204
|
+
if (!parsed || parsed.username || parsed.password)
|
|
205
|
+
refuse("url is not an absolute URL without userinfo");
|
|
206
|
+
url = parsed;
|
|
207
|
+
allowlisted = origins?.has(url.origin) ?? false;
|
|
208
|
+
if (origins && !allowlisted)
|
|
209
|
+
refuse("url is not on fileOrigins");
|
|
210
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && allowlisted && loopback(url.hostname)))
|
|
211
|
+
refuse("url is not https");
|
|
212
|
+
if (include && url.origin !== endpoint.origin)
|
|
213
|
+
refuse("credentials include is honored only at the server's origin");
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
const path = target.path;
|
|
217
|
+
if (!PATH.test(path))
|
|
218
|
+
refuse("path is not a valid path");
|
|
219
|
+
if (endpoint.protocol !== "https:" && !loopback(endpoint.hostname) && !origins?.has(endpoint.origin))
|
|
220
|
+
refuse("a path under an http MCP address needs loopback or fileOrigins");
|
|
221
|
+
url = new URL(endpoint);
|
|
222
|
+
url.pathname = url.pathname.replace(/\/$/, "") + path;
|
|
223
|
+
url.hash = "";
|
|
224
|
+
}
|
|
225
|
+
return { url: url.href, path: target.path !== undefined, allowlisted, include, headers, method: (target.method ?? "PUT"), form, expiresAt };
|
|
226
|
+
}
|
|
227
|
+
function send(target, init, { resolved, authorization }) {
|
|
228
|
+
const headers = new Headers(target.headers);
|
|
229
|
+
if (target.include && authorization)
|
|
230
|
+
headers.set("authorization", authorization);
|
|
231
|
+
if (target.path && resolved.installationUID)
|
|
232
|
+
headers.set("x-holon-installation-uid", resolved.installationUID);
|
|
233
|
+
return (target.path || target.allowlisted ? fetch : fetchGlobal)(target.url, { ...init, headers, credentials: "omit", redirect: "error" });
|
|
234
|
+
}
|
|
235
|
+
const reason = (error) => {
|
|
236
|
+
const { message, cause } = (error ?? {});
|
|
237
|
+
return String(cause?.message ?? message ?? error);
|
|
238
|
+
};
|
|
239
|
+
const TYPE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+\/[!#$%&'*+.^_`|~0-9A-Za-z-]+(?:[ \t]*;[ \t]*[!#$%&'*+.^_`|~0-9A-Za-z-]+=(?:[!#$%&'*+.^_`|~0-9A-Za-z-]+|"(?:[\t\x20\x21\x23-\x5b\x5d-\x7e]|\\[\t\x20-\x7e])*"))*$/;
|
|
240
|
+
// fetch's own FormData boundary has too little randomness for the extension (128 bits), so the body is built here,
|
|
241
|
+
// encoding names and the filename as the WHATWG multipart/form-data algorithm does.
|
|
242
|
+
function multipart({ fields, fileField }, { blob, input }) {
|
|
243
|
+
const boundary = `----holon${Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
244
|
+
const filename = (baseName(input.name) ?? "file").replaceAll('"', "%22").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
|
|
245
|
+
const parts = fields.map(({ name, value }) => `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`);
|
|
246
|
+
parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="${fileField}"; filename="${filename}"\r\nContent-Type: ${TYPE.test(input.mimeType) ? input.mimeType : "application/octet-stream"}\r\n\r\n`, blob, `\r\n--${boundary}--\r\n`);
|
|
247
|
+
return { body: new Blob(parts), type: `multipart/form-data; boundary=${boundary}` };
|
|
248
|
+
}
|
|
249
|
+
async function transfer(target, file, connection, signal) {
|
|
250
|
+
const { body, type } = target.form ? multipart(target.form, file) : { body: file.blob.slice(0, file.blob.size, ""), type: undefined };
|
|
251
|
+
for (let attempt = 0;; attempt++) {
|
|
252
|
+
if (expired(target))
|
|
253
|
+
throw new Error("its target expired");
|
|
254
|
+
let response;
|
|
255
|
+
try {
|
|
256
|
+
response = await send(type ? { ...target, headers: { ...target.headers, "Content-Type": type } } : target, { method: target.method, body, signal }, connection);
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
if (signal.aborted || target.method !== "PUT" || attempt === 2)
|
|
260
|
+
throw new Error(reason(error));
|
|
261
|
+
}
|
|
262
|
+
if (response) {
|
|
263
|
+
await response.body?.cancel();
|
|
264
|
+
if (response.ok)
|
|
265
|
+
return;
|
|
266
|
+
const retry = response.status >= 500 || response.status === 408 || response.status === 429;
|
|
267
|
+
if (target.method !== "PUT" || !retry || attempt === 2)
|
|
268
|
+
throw new Error(`HTTP ${response.status}`);
|
|
269
|
+
}
|
|
270
|
+
await new Promise(resolve => setTimeout(resolve, 200 * (attempt + 1)));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
export async function upload(request, files, connection, signal, label) {
|
|
274
|
+
const fail = (reason) => new Error(`${label}: ${reason}; the tool did not run.`);
|
|
275
|
+
const { requestState, uploads } = request;
|
|
276
|
+
if (typeof requestState !== "string")
|
|
277
|
+
throw fail("the server's upload request has no requestState");
|
|
278
|
+
const waiting = new Set([...files.values()].filter(({ input }) => input.size > 0 && input.data === undefined).map(({ input }) => input.id));
|
|
279
|
+
const ids = list(uploads).map(target => target?.fileId);
|
|
280
|
+
if (!Array.isArray(uploads) || ids.length !== waiting.size || new Set(ids).size !== ids.length || !ids.every(id => waiting.has(id))) {
|
|
281
|
+
throw fail("the server's upload request does not list each file sent without data exactly once");
|
|
282
|
+
}
|
|
283
|
+
const targets = uploads.map((target) => {
|
|
284
|
+
const file = files.get(target.fileId);
|
|
285
|
+
try {
|
|
286
|
+
return { file, target: check(target, "upload", connection) };
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
throw fail(`the upload target for ${quoted(file.input.name)} was rejected (${error.message})`);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
const controller = new AbortController();
|
|
293
|
+
const stop = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
294
|
+
await Promise.all(targets.map(async ({ file, target }) => {
|
|
295
|
+
try {
|
|
296
|
+
await transfer(target, file, connection, stop);
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
controller.abort();
|
|
300
|
+
throw fail(signal?.aborted ? "the call was cancelled during the upload" : `uploading ${quoted(file.input.name)} failed (${error.message})`);
|
|
301
|
+
}
|
|
302
|
+
}));
|
|
303
|
+
return requestState;
|
|
304
|
+
}
|
|
305
|
+
function download(raw, connection) {
|
|
306
|
+
const { name, mimeType, size, digest, expiresAt } = present(raw) ?? {};
|
|
307
|
+
const label = `Downloading ${quoted(typeof name === "string" ? name : undefined)}`;
|
|
308
|
+
const blob = async ({ signal } = {}) => {
|
|
309
|
+
let target;
|
|
310
|
+
try {
|
|
311
|
+
target = check(raw, "download", connection);
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
throw new Error(`${label} was refused: its target was rejected (${error.message}).`);
|
|
315
|
+
}
|
|
316
|
+
if (expired(target))
|
|
317
|
+
throw new Error(`${label} was refused: its target expired.`);
|
|
318
|
+
const declared = size;
|
|
319
|
+
const chunks = [];
|
|
320
|
+
let received = 0;
|
|
321
|
+
try {
|
|
322
|
+
const response = await send(target, { method: "GET", signal }, connection);
|
|
323
|
+
if (response.status !== 200) {
|
|
324
|
+
await response.body?.cancel();
|
|
325
|
+
throw new Error(`HTTP ${response.status}`);
|
|
326
|
+
}
|
|
327
|
+
const reader = response.body?.getReader();
|
|
328
|
+
while (reader) {
|
|
329
|
+
const chunk = await reader.read();
|
|
330
|
+
if (chunk.done)
|
|
331
|
+
break;
|
|
332
|
+
received += chunk.value.byteLength;
|
|
333
|
+
if (received > declared) {
|
|
334
|
+
await reader.cancel();
|
|
335
|
+
throw new Error(`more than the declared ${declared} bytes; discarded`);
|
|
336
|
+
}
|
|
337
|
+
chunks.push(chunk.value);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
throw new Error(`${label} failed (${reason(error)}).`);
|
|
342
|
+
}
|
|
343
|
+
if (received !== declared)
|
|
344
|
+
throw new Error(`${label} failed (${received} bytes, not the declared ${declared}; discarded).`);
|
|
345
|
+
const file = new Blob(chunks, { type: mimeType });
|
|
346
|
+
if (digest && await sha256(file) !== digest.value)
|
|
347
|
+
throw new Error(`${label} failed (the bytes do not match the digest; discarded).`);
|
|
348
|
+
return file;
|
|
349
|
+
};
|
|
350
|
+
return Object.freeze({ ...Object.fromEntries(Object.entries({ name, mimeType, size, digest, expiresAt }).filter(([, value]) => value !== undefined)), blob });
|
|
351
|
+
}
|
|
352
|
+
export function downloads(result, connection, enabled) {
|
|
353
|
+
if (result.resultType === "input_required" || !isObject(result._meta) || !(EXTENSION in result._meta))
|
|
354
|
+
return result;
|
|
355
|
+
const { [EXTENSION]: meta, ...rest } = result._meta;
|
|
356
|
+
const handles = enabled ? list(meta?.downloads).map(target => download(target, connection)) : [];
|
|
357
|
+
return { ...result, _meta: rest, ...(handles.length > 0 && { downloads: handles }) };
|
|
358
|
+
}
|
|
359
|
+
//# sourceMappingURL=files.client.js.map
|