glovebox-client 0.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.
- package/LICENSE.md +7 -0
- package/README.md +213 -0
- package/dist/index.d.ts +155 -0
- package/dist/index.js +353 -0
- package/package.json +42 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2026 dterminal
|
|
2
|
+
|
|
3
|
+
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:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# glovebox-client
|
|
2
|
+
|
|
3
|
+
Client SDK for talking to a deployed [Glovebox](https://github.com/porkytheblack/glove) server. One WebSocket per session, multiple prompts multiplexed. Streams subscriber events and display slot pushes; resolves with the final assistant message and an outputs map of `FileRef`s the client can read back through the configured storage.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add glovebox-client
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Works in Node (uses `ws`) and the browser (uses the global `WebSocket`). The wire format and auth are identical.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Connecting
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { GloveboxClient } from "glovebox-client"
|
|
19
|
+
|
|
20
|
+
const client = GloveboxClient.make({
|
|
21
|
+
endpoints: {
|
|
22
|
+
media: { url: "wss://media.example.com/", key: process.env.GLOVEBOX_MEDIA_KEY! },
|
|
23
|
+
docs: { url: "wss://docs.example.com/", key: process.env.GLOVEBOX_DOCS_KEY! },
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const media = client.box("media")
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`client.box(name)` lazily opens a connection on the first prompt and caches the `Box` afterward. The bearer key from the endpoint config is sent as `Authorization: Bearer ...` on the WS upgrade and on every subsequent HTTP request the SDK makes (`/environment`, `/files/:id`).
|
|
31
|
+
|
|
32
|
+
### Prompting
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { readFile } from "node:fs/promises"
|
|
36
|
+
|
|
37
|
+
const result = media.prompt("Trim the first 30 seconds off in.mp4 and write trimmed.mp4.", {
|
|
38
|
+
files: {
|
|
39
|
+
"in.mp4": { mime: "video/mp4", bytes: await readFile("./in.mp4") },
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
for await (const ev of result.events) {
|
|
44
|
+
if (ev.event_type === "text_delta") process.stdout.write((ev.data as { text: string }).text)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const message = await result.message
|
|
48
|
+
const outputs = await result.outputs
|
|
49
|
+
const trimmed = await result.read("trimmed.mp4")
|
|
50
|
+
await writeFile("./trimmed.mp4", trimmed)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`prompt(text, opts)` returns a `PromptResult` synchronously — the call doesn't await the round-trip. The four async iterables / promises on the result fan out as the server sends frames:
|
|
54
|
+
|
|
55
|
+
| Member | Type | Settles when |
|
|
56
|
+
|--------|------|--------------|
|
|
57
|
+
| `events` | `AsyncIterable<SubscriberEvent>` | Closed at `complete` / `error`. |
|
|
58
|
+
| `display` | `AsyncIterable<DisplayEvent>` | Closed at `complete` / `error`. Display events are session-scoped on the server, so they fan out to every active prompt. |
|
|
59
|
+
| `message` | `Promise<string>` | Final assistant text from the `complete` frame. |
|
|
60
|
+
| `outputs` | `Promise<Record<string, FileRef>>` | Outputs map from the `complete` frame. |
|
|
61
|
+
| `read(name)` | `Promise<Uint8Array>` | Awaits `outputs`, looks up the named ref, fetches it through the configured `ClientStorage`. |
|
|
62
|
+
| `resolve(slot_id, value)` | `void` | Sends a display resolution back to the server. |
|
|
63
|
+
| `reject(slot_id, error)` | `void` | Sends a display rejection back. |
|
|
64
|
+
| `abort()` | `void` | Sends `{ type: "abort", id }`. |
|
|
65
|
+
|
|
66
|
+
### Display slots
|
|
67
|
+
|
|
68
|
+
When a tool inside the agent calls `display.pushAndWait(...)`, the server emits a `display_push`. Route slot pushes by `slot.renderer`, render the input, and call `result.resolve(slot.id, value)` once the user submits.
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
const result = media.prompt("Pick a frame to use as the thumbnail.")
|
|
72
|
+
|
|
73
|
+
for await (const ev of result.display) {
|
|
74
|
+
if (ev.type === "push" && ev.slot) {
|
|
75
|
+
const slot = ev.slot
|
|
76
|
+
if (slot.renderer === "frame_picker") {
|
|
77
|
+
const choice = await renderFramePicker(slot.input)
|
|
78
|
+
result.resolve(slot.id, choice)
|
|
79
|
+
}
|
|
80
|
+
} else if (ev.type === "clear") {
|
|
81
|
+
clearSlot(ev.slot_id!)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Reading the environment
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
const env = await media.environment()
|
|
90
|
+
// env.fs.input.path === "/input"
|
|
91
|
+
// env.packages.apt?.includes("ffmpeg")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Cached after the first call. Useful when the same client holds many endpoints and routes prompts based on declared capabilities. Backed by `GET /environment` on the server.
|
|
95
|
+
|
|
96
|
+
### Send-side errors
|
|
97
|
+
|
|
98
|
+
Outgoing frames (`prompt`, `abort`, `display_resolve`, `display_reject`) are dispatched through `void this.send(...)`. Failures there are surfaced via:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
const off = media.onSendError((err) => {
|
|
102
|
+
console.error("[media] send failed:", err)
|
|
103
|
+
})
|
|
104
|
+
// later
|
|
105
|
+
off()
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
If no listener is registered, the SDK warns to `console.warn` so the failure doesn't disappear silently.
|
|
109
|
+
|
|
110
|
+
### Lifecycle
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
await media.close() // closes one box
|
|
114
|
+
await client.close() // closes every cached box
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Closing rejects every in-flight prompt's `message` and `outputs` and closes the corresponding event/display iterators. The connection is otherwise managed lazily — `prompt()` re-opens after `close()` only if you reach back through `client.box(...)` for a fresh `Box`.
|
|
118
|
+
|
|
119
|
+
## Custom storage
|
|
120
|
+
|
|
121
|
+
`DefaultClientStorage` puts files inline (base64) and reads `inline | url | server` refs. That's enough for a lot of agents — tens of MB ride fine — but exceeds what you want over a single WS frame at some point.
|
|
122
|
+
|
|
123
|
+
Provide a `ClientStorage` to split big inputs out. The contract is two methods:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
interface ClientStorage {
|
|
127
|
+
put(name: string, mime: string, bytes: Uint8Array): Promise<FileRef>
|
|
128
|
+
get(ref: FileRef, opts?: { bearer?: string }): Promise<Uint8Array>
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Example: pre-sign an S3 URL on your backend, hand the `s3` ref to the server.
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
import type { ClientStorage } from "glovebox-client"
|
|
136
|
+
import type { FileRef } from "glovebox-client"
|
|
137
|
+
|
|
138
|
+
class S3UploadingStorage implements ClientStorage {
|
|
139
|
+
async put(name, mime, bytes): Promise<FileRef> {
|
|
140
|
+
const { bucket, key, putUrl } = await fetch("/api/sign-upload", {
|
|
141
|
+
method: "POST",
|
|
142
|
+
body: JSON.stringify({ name, mime, size: bytes.length }),
|
|
143
|
+
}).then((r) => r.json())
|
|
144
|
+
await fetch(putUrl, { method: "PUT", body: bytes, headers: { "Content-Type": mime } })
|
|
145
|
+
return { kind: "s3", name, mime, bucket, key }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async get(ref: FileRef, opts) {
|
|
149
|
+
if (ref.kind === "s3") {
|
|
150
|
+
const { getUrl } = await fetch("/api/sign-download", {
|
|
151
|
+
method: "POST",
|
|
152
|
+
body: JSON.stringify({ bucket: ref.bucket, key: ref.key }),
|
|
153
|
+
}).then((r) => r.json())
|
|
154
|
+
return new Uint8Array(await fetch(getUrl).then((r) => r.arrayBuffer()))
|
|
155
|
+
}
|
|
156
|
+
return new DefaultClientStorage().get(ref, opts)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const client = GloveboxClient.make({
|
|
161
|
+
endpoints: { media: { url, key } },
|
|
162
|
+
storage: new S3UploadingStorage(),
|
|
163
|
+
})
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The same storage is used for outputs: `result.read(name)` calls `storage.get(ref, { bearer })`, so `s3` refs returned by the server flow through your custom adapter too. The bearer is forwarded only for `server`-kind refs (the kit's authenticated `/files/:id` route).
|
|
167
|
+
|
|
168
|
+
`PromptOptions.inputs` lets you pass pre-built `FileRef`s alongside (or instead of) raw `files` — handy when the bytes already live somewhere the server can read directly.
|
|
169
|
+
|
|
170
|
+
## Errors
|
|
171
|
+
|
|
172
|
+
Server `error` frames reject `result.message` and `result.outputs` with an `Error` carrying the server-supplied `code` (assignable as `(err as Error & { code: string }).code`). The connection itself is preserved — only the failing prompt is dropped. A WS close drops every in-flight prompt with `Error("Connection closed")`. There is no automatic reconnect in v1; a fresh prompt on a closed `Box` throws.
|
|
173
|
+
|
|
174
|
+
## Public surface
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import {
|
|
178
|
+
GloveboxClient,
|
|
179
|
+
Box,
|
|
180
|
+
DefaultClientStorage,
|
|
181
|
+
type GloveboxClientOptions,
|
|
182
|
+
type BoxEndpoint,
|
|
183
|
+
type BoxOptions,
|
|
184
|
+
type PromptOptions,
|
|
185
|
+
type PromptResult,
|
|
186
|
+
type SubscriberEvent,
|
|
187
|
+
type DisplayEvent,
|
|
188
|
+
type BoxEnvironment,
|
|
189
|
+
type ClientStorage,
|
|
190
|
+
type DefaultClientStorageOptions,
|
|
191
|
+
type FileRef,
|
|
192
|
+
type SubscriberEventType,
|
|
193
|
+
type WireSlot,
|
|
194
|
+
} from "glovebox-client"
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Status
|
|
198
|
+
|
|
199
|
+
v1. The wire protocol is `protocol_version: 1`. Connections are bearer-authed and prompts within a session run sequentially on the server. There is no client-side reconnect / resume — when the socket drops, in-flight prompts reject. JWT auth, multiplexed execution, and a hosted glovebox.dev tier are deferred to v2.
|
|
200
|
+
|
|
201
|
+
## Companion packages
|
|
202
|
+
|
|
203
|
+
- **[`glovebox-core`](../glovebox/README.md)** — authoring kit + `glovebox build` CLI.
|
|
204
|
+
- **[`glovebox-kit`](../glovebox-kit/README.md)** — in-container runtime.
|
|
205
|
+
|
|
206
|
+
## Documentation
|
|
207
|
+
|
|
208
|
+
- [Glovebox Guide](https://glove.dterminal.net/docs/glovebox)
|
|
209
|
+
- [Full Documentation](https://glove.dterminal.net)
|
|
210
|
+
|
|
211
|
+
## License
|
|
212
|
+
|
|
213
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { FileRef, SubscriberEventType, WireSlot } from 'glovebox-core/protocol';
|
|
2
|
+
export { FileRef, SubscriberEventType, WireSlot } from 'glovebox-core/protocol';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Client-side storage helpers. Symmetric to the server: the client uploads
|
|
6
|
+
* inputs, the server uploads outputs. v1 only ships the two adapters that
|
|
7
|
+
* don't need extra runtime services: inline (for small payloads) and url
|
|
8
|
+
* (for caller-supplied URLs).
|
|
9
|
+
*/
|
|
10
|
+
interface ClientStorage {
|
|
11
|
+
/** Wrap raw bytes into a FileRef the server can read. */
|
|
12
|
+
put(name: string, mime: string, bytes: Uint8Array): Promise<FileRef>;
|
|
13
|
+
/** Read a FileRef received from the server back into bytes. */
|
|
14
|
+
get(ref: FileRef, opts?: {
|
|
15
|
+
bearer?: string;
|
|
16
|
+
}): Promise<Uint8Array>;
|
|
17
|
+
}
|
|
18
|
+
interface DefaultClientStorageOptions {
|
|
19
|
+
/** Inline below this many bytes; throws above. Default: no upper bound. */
|
|
20
|
+
inlineMaxBytes?: number;
|
|
21
|
+
}
|
|
22
|
+
declare class DefaultClientStorage implements ClientStorage {
|
|
23
|
+
private readonly opts;
|
|
24
|
+
constructor(opts?: DefaultClientStorageOptions);
|
|
25
|
+
put(name: string, mime: string, bytes: Uint8Array): Promise<FileRef>;
|
|
26
|
+
get(ref: FileRef, opts?: {
|
|
27
|
+
bearer?: string;
|
|
28
|
+
}): Promise<Uint8Array>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface BoxEndpoint {
|
|
32
|
+
url: string;
|
|
33
|
+
key: string;
|
|
34
|
+
}
|
|
35
|
+
interface BoxOptions {
|
|
36
|
+
endpoint: BoxEndpoint;
|
|
37
|
+
storage?: ClientStorage;
|
|
38
|
+
/**
|
|
39
|
+
* Reconnect attempts on connection drop. Default 3, exponential backoff
|
|
40
|
+
* 500ms / 1s / 2s.
|
|
41
|
+
*/
|
|
42
|
+
reconnectAttempts?: number;
|
|
43
|
+
}
|
|
44
|
+
interface PromptOptions {
|
|
45
|
+
/** Caller-supplied input files. Bytes get wrapped via the configured ClientStorage. */
|
|
46
|
+
files?: Record<string, {
|
|
47
|
+
mime?: string;
|
|
48
|
+
bytes: Uint8Array;
|
|
49
|
+
}>;
|
|
50
|
+
/** Pre-computed FileRefs (bypasses ClientStorage). Merged with `files`. */
|
|
51
|
+
inputs?: Record<string, FileRef>;
|
|
52
|
+
}
|
|
53
|
+
interface SubscriberEvent {
|
|
54
|
+
request_id: string;
|
|
55
|
+
event_type: SubscriberEventType;
|
|
56
|
+
data: unknown;
|
|
57
|
+
}
|
|
58
|
+
interface DisplayEvent {
|
|
59
|
+
type: "push" | "clear";
|
|
60
|
+
slot?: WireSlot;
|
|
61
|
+
slot_id?: string;
|
|
62
|
+
}
|
|
63
|
+
interface PromptResult {
|
|
64
|
+
/** Async iterable of subscriber events (text deltas, tool uses, etc.). */
|
|
65
|
+
events: AsyncIterable<SubscriberEvent>;
|
|
66
|
+
/** Async iterable of display slot pushes/clears. */
|
|
67
|
+
display: AsyncIterable<DisplayEvent>;
|
|
68
|
+
/** Resolves with the final assistant message. */
|
|
69
|
+
message: Promise<string>;
|
|
70
|
+
/** Resolves with the final outputs map. */
|
|
71
|
+
outputs: Promise<Record<string, FileRef>>;
|
|
72
|
+
/** Read an output file's bytes through the configured storage. */
|
|
73
|
+
read(name: string): Promise<Uint8Array>;
|
|
74
|
+
/** Send a display resolution back to the server. */
|
|
75
|
+
resolve(slot_id: string, value: unknown): void;
|
|
76
|
+
/** Send a display rejection back to the server. */
|
|
77
|
+
reject(slot_id: string, error: unknown): void;
|
|
78
|
+
/** Abort this prompt. */
|
|
79
|
+
abort(): void;
|
|
80
|
+
}
|
|
81
|
+
interface BoxEnvironment {
|
|
82
|
+
name: string;
|
|
83
|
+
version: string;
|
|
84
|
+
base: string;
|
|
85
|
+
fs: Record<string, {
|
|
86
|
+
path: string;
|
|
87
|
+
writable: boolean;
|
|
88
|
+
}>;
|
|
89
|
+
packages: {
|
|
90
|
+
apt?: string[];
|
|
91
|
+
pip?: string[];
|
|
92
|
+
npm?: string[];
|
|
93
|
+
};
|
|
94
|
+
limits?: {
|
|
95
|
+
cpu?: string;
|
|
96
|
+
memory?: string;
|
|
97
|
+
timeout?: string;
|
|
98
|
+
};
|
|
99
|
+
protocol_version: 1;
|
|
100
|
+
}
|
|
101
|
+
declare class Box {
|
|
102
|
+
private readonly opts;
|
|
103
|
+
private ws;
|
|
104
|
+
private opening;
|
|
105
|
+
private closed;
|
|
106
|
+
private nextId;
|
|
107
|
+
private readonly inflight;
|
|
108
|
+
private readonly storage;
|
|
109
|
+
constructor(opts: BoxOptions);
|
|
110
|
+
get bearer(): string;
|
|
111
|
+
close(): Promise<void>;
|
|
112
|
+
prompt(text: string, opts?: PromptOptions): PromptResult;
|
|
113
|
+
/**
|
|
114
|
+
* Fetch the deployed glovebox's environment spec — useful for routing
|
|
115
|
+
* decisions when an app holds many endpoints. Cached after first fetch.
|
|
116
|
+
*/
|
|
117
|
+
environment(): Promise<BoxEnvironment>;
|
|
118
|
+
/** Subscribe to send-side errors that escape `void this.send(...)` callsites. */
|
|
119
|
+
onSendError(listener: (err: unknown) => void): () => void;
|
|
120
|
+
private envCache?;
|
|
121
|
+
private sendErrorListeners;
|
|
122
|
+
private emitSendError;
|
|
123
|
+
private dispatchPrompt;
|
|
124
|
+
private send;
|
|
125
|
+
private ensureOpen;
|
|
126
|
+
private openSocket;
|
|
127
|
+
private handleMessage;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface GloveboxClientOptions {
|
|
131
|
+
endpoints: Record<string, BoxEndpoint>;
|
|
132
|
+
storage?: ClientStorage;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Holds a registry of named glovebox endpoints and lazily constructs `Box`
|
|
136
|
+
* connections on demand.
|
|
137
|
+
*
|
|
138
|
+
* const client = GloveboxClient.make({
|
|
139
|
+
* endpoints: {
|
|
140
|
+
* media: { url: "wss://media.example.com/run", key: process.env.GLOVEBOX_MEDIA_KEY! },
|
|
141
|
+
* },
|
|
142
|
+
* })
|
|
143
|
+
*
|
|
144
|
+
* const result = client.box("media").prompt("trim this", { files: { "in.mp4": ... } })
|
|
145
|
+
*/
|
|
146
|
+
declare class GloveboxClient {
|
|
147
|
+
private readonly opts;
|
|
148
|
+
private boxes;
|
|
149
|
+
private constructor();
|
|
150
|
+
static make(opts: GloveboxClientOptions): GloveboxClient;
|
|
151
|
+
box(name: string): Box;
|
|
152
|
+
close(): Promise<void>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export { Box, type BoxEndpoint, type BoxEnvironment, type BoxOptions, type ClientStorage, DefaultClientStorage, type DefaultClientStorageOptions, type DisplayEvent, GloveboxClient, type GloveboxClientOptions, type PromptOptions, type PromptResult, type SubscriberEvent };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// src/storage.ts
|
|
2
|
+
var DefaultClientStorage = class {
|
|
3
|
+
constructor(opts = {}) {
|
|
4
|
+
this.opts = opts;
|
|
5
|
+
}
|
|
6
|
+
async put(name, mime, bytes) {
|
|
7
|
+
if (this.opts.inlineMaxBytes !== void 0 && bytes.length > this.opts.inlineMaxBytes) {
|
|
8
|
+
throw new Error(
|
|
9
|
+
`File ${name} (${bytes.length} bytes) exceeds inlineMaxBytes (${this.opts.inlineMaxBytes}); provide a custom ClientStorage that uploads to S3 or another backend`
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
kind: "inline",
|
|
14
|
+
name,
|
|
15
|
+
mime,
|
|
16
|
+
data: bytesToBase64(bytes)
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
async get(ref, opts) {
|
|
20
|
+
if (ref.kind === "inline") return base64ToBytes(ref.data);
|
|
21
|
+
if (ref.kind === "url" || ref.kind === "server") {
|
|
22
|
+
const headers = ref.kind === "url" && ref.headers ? { ...ref.headers } : {};
|
|
23
|
+
if (ref.kind === "server" && opts?.bearer) {
|
|
24
|
+
headers["Authorization"] = `Bearer ${opts.bearer}`;
|
|
25
|
+
}
|
|
26
|
+
const res = await fetch(ref.url, { headers });
|
|
27
|
+
if (!res.ok) throw new Error(`Failed to fetch ${ref.url}: ${res.status} ${res.statusText}`);
|
|
28
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
29
|
+
}
|
|
30
|
+
throw new Error(`DefaultClientStorage cannot get ref of kind ${ref.kind}; pass a custom ClientStorage`);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
function bytesToBase64(bytes) {
|
|
34
|
+
if (typeof Buffer !== "undefined") return Buffer.from(bytes).toString("base64");
|
|
35
|
+
let s = "";
|
|
36
|
+
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
|
37
|
+
return btoa(s);
|
|
38
|
+
}
|
|
39
|
+
function base64ToBytes(b64) {
|
|
40
|
+
if (typeof Buffer !== "undefined") return new Uint8Array(Buffer.from(b64, "base64"));
|
|
41
|
+
const bin = atob(b64);
|
|
42
|
+
const out = new Uint8Array(bin.length);
|
|
43
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/wire.ts
|
|
48
|
+
async function pickWebSocket() {
|
|
49
|
+
if (typeof globalThis !== "undefined" && typeof globalThis.WebSocket === "function") {
|
|
50
|
+
return globalThis.WebSocket;
|
|
51
|
+
}
|
|
52
|
+
const mod = await import("ws");
|
|
53
|
+
return mod.WebSocket;
|
|
54
|
+
}
|
|
55
|
+
function httpFromWs(wsUrl, pathname) {
|
|
56
|
+
const u = new URL(wsUrl);
|
|
57
|
+
if (u.protocol === "wss:") u.protocol = "https:";
|
|
58
|
+
else if (u.protocol === "ws:") u.protocol = "http:";
|
|
59
|
+
u.pathname = pathname;
|
|
60
|
+
u.search = "";
|
|
61
|
+
return u.toString();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/box.ts
|
|
65
|
+
function asyncQueue() {
|
|
66
|
+
const buf = [];
|
|
67
|
+
let waker = null;
|
|
68
|
+
let closed = false;
|
|
69
|
+
const next = () => {
|
|
70
|
+
if (buf.length > 0) return Promise.resolve({ value: buf.shift(), done: false });
|
|
71
|
+
if (closed) return Promise.resolve({ value: void 0, done: true });
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
waker = resolve;
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
push(value) {
|
|
78
|
+
if (closed) return;
|
|
79
|
+
if (waker) {
|
|
80
|
+
const w = waker;
|
|
81
|
+
waker = null;
|
|
82
|
+
w({ value, done: false });
|
|
83
|
+
} else {
|
|
84
|
+
buf.push(value);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
close() {
|
|
88
|
+
closed = true;
|
|
89
|
+
if (waker) {
|
|
90
|
+
const w = waker;
|
|
91
|
+
waker = null;
|
|
92
|
+
w({ value: void 0, done: true });
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
iter() {
|
|
96
|
+
return { [Symbol.asyncIterator]: () => ({ next }) };
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
var Box = class {
|
|
101
|
+
constructor(opts) {
|
|
102
|
+
this.opts = opts;
|
|
103
|
+
this.storage = opts.storage ?? new DefaultClientStorage();
|
|
104
|
+
}
|
|
105
|
+
ws = null;
|
|
106
|
+
opening = null;
|
|
107
|
+
closed = false;
|
|
108
|
+
nextId = 1;
|
|
109
|
+
inflight = /* @__PURE__ */ new Map();
|
|
110
|
+
storage;
|
|
111
|
+
get bearer() {
|
|
112
|
+
return this.opts.endpoint.key;
|
|
113
|
+
}
|
|
114
|
+
async close() {
|
|
115
|
+
this.closed = true;
|
|
116
|
+
if (this.ws) this.ws.close(1e3, "client closed");
|
|
117
|
+
for (const inflight of this.inflight.values()) {
|
|
118
|
+
inflight.events.close();
|
|
119
|
+
inflight.display.close();
|
|
120
|
+
inflight.rejectMessage(new Error("Connection closed"));
|
|
121
|
+
inflight.rejectOutputs(new Error("Connection closed"));
|
|
122
|
+
}
|
|
123
|
+
this.inflight.clear();
|
|
124
|
+
}
|
|
125
|
+
prompt(text, opts) {
|
|
126
|
+
if (this.closed) throw new Error("Box is closed");
|
|
127
|
+
const id = `req_${this.nextId++}`;
|
|
128
|
+
const events = asyncQueue();
|
|
129
|
+
const display = asyncQueue();
|
|
130
|
+
let resolveMessage = () => void 0;
|
|
131
|
+
let rejectMessage = () => void 0;
|
|
132
|
+
const messagePromise = new Promise((res, rej) => {
|
|
133
|
+
resolveMessage = res;
|
|
134
|
+
rejectMessage = rej;
|
|
135
|
+
});
|
|
136
|
+
let resolveOutputs = () => void 0;
|
|
137
|
+
let rejectOutputs = () => void 0;
|
|
138
|
+
const outputsPromise = new Promise((res, rej) => {
|
|
139
|
+
resolveOutputs = res;
|
|
140
|
+
rejectOutputs = rej;
|
|
141
|
+
});
|
|
142
|
+
this.inflight.set(id, {
|
|
143
|
+
events,
|
|
144
|
+
display,
|
|
145
|
+
resolveMessage,
|
|
146
|
+
rejectMessage,
|
|
147
|
+
resolveOutputs,
|
|
148
|
+
rejectOutputs
|
|
149
|
+
});
|
|
150
|
+
void this.dispatchPrompt(id, text, opts).catch((err) => {
|
|
151
|
+
const inflight = this.inflight.get(id);
|
|
152
|
+
if (inflight) {
|
|
153
|
+
inflight.events.close();
|
|
154
|
+
inflight.display.close();
|
|
155
|
+
inflight.rejectMessage(err);
|
|
156
|
+
inflight.rejectOutputs(err);
|
|
157
|
+
this.inflight.delete(id);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
const sendOrEmit = (msg) => {
|
|
161
|
+
this.send(msg).catch((err) => {
|
|
162
|
+
this.emitSendError(err);
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
const result = {
|
|
166
|
+
events: events.iter(),
|
|
167
|
+
display: display.iter(),
|
|
168
|
+
message: messagePromise,
|
|
169
|
+
outputs: outputsPromise,
|
|
170
|
+
read: async (name) => {
|
|
171
|
+
const outputs = await outputsPromise;
|
|
172
|
+
const ref = outputs[name];
|
|
173
|
+
if (!ref) throw new Error(`No output named ${name}`);
|
|
174
|
+
return this.storage.get(ref, { bearer: this.bearer });
|
|
175
|
+
},
|
|
176
|
+
resolve: (slot_id, value) => sendOrEmit({ type: "display_resolve", slot_id, value }),
|
|
177
|
+
reject: (slot_id, error) => sendOrEmit({ type: "display_reject", slot_id, error }),
|
|
178
|
+
abort: () => sendOrEmit({ type: "abort", id })
|
|
179
|
+
};
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Fetch the deployed glovebox's environment spec — useful for routing
|
|
184
|
+
* decisions when an app holds many endpoints. Cached after first fetch.
|
|
185
|
+
*/
|
|
186
|
+
async environment() {
|
|
187
|
+
if (this.envCache) return this.envCache;
|
|
188
|
+
const url = httpFromWs(this.opts.endpoint.url, "/environment");
|
|
189
|
+
const res = await fetch(url, {
|
|
190
|
+
headers: { Authorization: `Bearer ${this.bearer}` }
|
|
191
|
+
});
|
|
192
|
+
if (!res.ok) {
|
|
193
|
+
throw new Error(`environment fetch failed: ${res.status} ${res.statusText}`);
|
|
194
|
+
}
|
|
195
|
+
const env = await res.json();
|
|
196
|
+
this.envCache = env;
|
|
197
|
+
return env;
|
|
198
|
+
}
|
|
199
|
+
/** Subscribe to send-side errors that escape `void this.send(...)` callsites. */
|
|
200
|
+
onSendError(listener) {
|
|
201
|
+
this.sendErrorListeners.add(listener);
|
|
202
|
+
return () => this.sendErrorListeners.delete(listener);
|
|
203
|
+
}
|
|
204
|
+
envCache;
|
|
205
|
+
sendErrorListeners = /* @__PURE__ */ new Set();
|
|
206
|
+
emitSendError(err) {
|
|
207
|
+
if (this.sendErrorListeners.size === 0) {
|
|
208
|
+
console.warn("[glovebox-client] send failed:", err);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
for (const fn of this.sendErrorListeners) {
|
|
212
|
+
try {
|
|
213
|
+
fn(err);
|
|
214
|
+
} catch {
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// ─── internals ────────────────────────────────────────────────────────
|
|
219
|
+
async dispatchPrompt(id, text, opts) {
|
|
220
|
+
const inputs = { ...opts?.inputs ?? {} };
|
|
221
|
+
if (opts?.files) {
|
|
222
|
+
for (const [name, f] of Object.entries(opts.files)) {
|
|
223
|
+
inputs[name] = await this.storage.put(name, f.mime ?? "application/octet-stream", f.bytes);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
await this.send({ type: "prompt", id, text, inputs });
|
|
227
|
+
}
|
|
228
|
+
async send(msg) {
|
|
229
|
+
const ws = await this.ensureOpen();
|
|
230
|
+
ws.send(JSON.stringify(msg));
|
|
231
|
+
}
|
|
232
|
+
async ensureOpen() {
|
|
233
|
+
if (this.ws) return this.ws;
|
|
234
|
+
if (this.opening) return this.opening;
|
|
235
|
+
this.opening = this.openSocket();
|
|
236
|
+
try {
|
|
237
|
+
this.ws = await this.opening;
|
|
238
|
+
return this.ws;
|
|
239
|
+
} finally {
|
|
240
|
+
this.opening = null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async openSocket() {
|
|
244
|
+
const Ctor = await pickWebSocket();
|
|
245
|
+
const headers = { Authorization: `Bearer ${this.opts.endpoint.key}` };
|
|
246
|
+
const ws = new Ctor(this.opts.endpoint.url, void 0, { headers });
|
|
247
|
+
await new Promise((resolve, reject) => {
|
|
248
|
+
ws.addEventListener("open", () => resolve());
|
|
249
|
+
ws.addEventListener("error", (event) => reject(new Error(event.message ?? "websocket error")));
|
|
250
|
+
});
|
|
251
|
+
ws.addEventListener("message", (event) => {
|
|
252
|
+
let msg;
|
|
253
|
+
try {
|
|
254
|
+
msg = JSON.parse(typeof event.data === "string" ? event.data : String(event.data));
|
|
255
|
+
} catch {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
this.handleMessage(msg);
|
|
259
|
+
});
|
|
260
|
+
ws.addEventListener("close", () => {
|
|
261
|
+
this.ws = null;
|
|
262
|
+
for (const [, inflight] of this.inflight) {
|
|
263
|
+
inflight.events.close();
|
|
264
|
+
inflight.display.close();
|
|
265
|
+
inflight.rejectMessage(new Error("Connection closed"));
|
|
266
|
+
inflight.rejectOutputs(new Error("Connection closed"));
|
|
267
|
+
}
|
|
268
|
+
this.inflight.clear();
|
|
269
|
+
});
|
|
270
|
+
return ws;
|
|
271
|
+
}
|
|
272
|
+
handleMessage(msg) {
|
|
273
|
+
switch (msg.type) {
|
|
274
|
+
case "event": {
|
|
275
|
+
const inflight = this.inflight.get(msg.id);
|
|
276
|
+
if (inflight) {
|
|
277
|
+
inflight.events.push({
|
|
278
|
+
request_id: msg.id,
|
|
279
|
+
event_type: msg.event_type,
|
|
280
|
+
data: msg.data
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
case "display_push": {
|
|
286
|
+
for (const inflight of this.inflight.values()) {
|
|
287
|
+
inflight.display.push({ type: "push", slot: msg.slot });
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
case "display_clear": {
|
|
292
|
+
for (const inflight of this.inflight.values()) {
|
|
293
|
+
inflight.display.push({ type: "clear", slot_id: msg.slot_id });
|
|
294
|
+
}
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
case "complete": {
|
|
298
|
+
const inflight = this.inflight.get(msg.id);
|
|
299
|
+
if (inflight) {
|
|
300
|
+
inflight.resolveOutputs(msg.outputs);
|
|
301
|
+
inflight.resolveMessage(msg.message);
|
|
302
|
+
inflight.events.close();
|
|
303
|
+
inflight.display.close();
|
|
304
|
+
this.inflight.delete(msg.id);
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
case "error": {
|
|
309
|
+
const inflight = this.inflight.get(msg.id);
|
|
310
|
+
if (inflight) {
|
|
311
|
+
const err = Object.assign(new Error(msg.error.message), { code: msg.error.code });
|
|
312
|
+
inflight.rejectMessage(err);
|
|
313
|
+
inflight.rejectOutputs(err);
|
|
314
|
+
inflight.events.close();
|
|
315
|
+
inflight.display.close();
|
|
316
|
+
this.inflight.delete(msg.id);
|
|
317
|
+
}
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
case "pong":
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// src/client.ts
|
|
327
|
+
var GloveboxClient = class _GloveboxClient {
|
|
328
|
+
constructor(opts) {
|
|
329
|
+
this.opts = opts;
|
|
330
|
+
}
|
|
331
|
+
boxes = /* @__PURE__ */ new Map();
|
|
332
|
+
static make(opts) {
|
|
333
|
+
return new _GloveboxClient(opts);
|
|
334
|
+
}
|
|
335
|
+
box(name) {
|
|
336
|
+
const cached = this.boxes.get(name);
|
|
337
|
+
if (cached) return cached;
|
|
338
|
+
const endpoint = this.opts.endpoints[name];
|
|
339
|
+
if (!endpoint) throw new Error(`Unknown glovebox endpoint: ${name}`);
|
|
340
|
+
const box = new Box({ endpoint, storage: this.opts.storage });
|
|
341
|
+
this.boxes.set(name, box);
|
|
342
|
+
return box;
|
|
343
|
+
}
|
|
344
|
+
async close() {
|
|
345
|
+
await Promise.all([...this.boxes.values()].map((b) => b.close()));
|
|
346
|
+
this.boxes.clear();
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
export {
|
|
350
|
+
Box,
|
|
351
|
+
DefaultClientStorage,
|
|
352
|
+
GloveboxClient
|
|
353
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "glovebox-client",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Client SDK for talking to a deployed Glovebox server",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/porkytheblack/glove.git",
|
|
10
|
+
"directory": "packages/glovebox-client"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/porkytheblack/glove",
|
|
13
|
+
"bugs": "https://github.com/porkytheblack/glove/issues",
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"ws": "^8.18.0",
|
|
30
|
+
"glovebox-core": "0.5.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^25.2.3",
|
|
34
|
+
"@types/ws": "^8.5.13",
|
|
35
|
+
"tsup": "^8.5.1",
|
|
36
|
+
"typescript": "^5.9.3"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsup",
|
|
40
|
+
"typecheck": "tsc --noEmit"
|
|
41
|
+
}
|
|
42
|
+
}
|