@toshicloud/sdk 0.0.0-stage → 0.3.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 (45) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +250 -2
  3. package/dist/client.d.ts +153 -0
  4. package/dist/client.js +92 -0
  5. package/dist/errors.d.ts +28 -0
  6. package/dist/errors.js +77 -0
  7. package/dist/execute.d.ts +7 -0
  8. package/dist/execute.js +380 -0
  9. package/dist/gen/toshi/workspace/v1/artifacts_pb.d.ts +843 -0
  10. package/dist/gen/toshi/workspace/v1/artifacts_pb.js +96 -0
  11. package/dist/gen/toshi/workspace/v1/channels_pb.d.ts +2713 -0
  12. package/dist/gen/toshi/workspace/v1/channels_pb.js +336 -0
  13. package/dist/gen/toshi/workspace/v1/display_pb.d.ts +1166 -0
  14. package/dist/gen/toshi/workspace/v1/display_pb.js +214 -0
  15. package/dist/gen/toshi/workspace/v1/projects_pb.d.ts +1540 -0
  16. package/dist/gen/toshi/workspace/v1/projects_pb.js +207 -0
  17. package/dist/gen/toshi/workspace/v1/recordings_pb.d.ts +1024 -0
  18. package/dist/gen/toshi/workspace/v1/recordings_pb.js +201 -0
  19. package/dist/gen/toshi/workspace/v1/secrets_pb.d.ts +1214 -0
  20. package/dist/gen/toshi/workspace/v1/secrets_pb.js +182 -0
  21. package/dist/gen/toshi/workspace/v1/volumes_pb.d.ts +444 -0
  22. package/dist/gen/toshi/workspace/v1/volumes_pb.js +107 -0
  23. package/dist/gen/toshi/workspace/v1/webhook_pb.d.ts +1018 -0
  24. package/dist/gen/toshi/workspace/v1/webhook_pb.js +153 -0
  25. package/dist/gen/toshi/workspace/v1/workloads_pb.d.ts +2858 -0
  26. package/dist/gen/toshi/workspace/v1/workloads_pb.js +261 -0
  27. package/dist/gen/toshi/workspace/v1/workspace_pb.d.ts +5380 -0
  28. package/dist/gen/toshi/workspace/v1/workspace_pb.js +855 -0
  29. package/dist/index.d.ts +15 -0
  30. package/dist/index.js +14 -0
  31. package/dist/interceptors.d.ts +10 -0
  32. package/dist/interceptors.js +159 -0
  33. package/dist/output.d.ts +7 -0
  34. package/dist/output.js +77 -0
  35. package/dist/retry.d.ts +42 -0
  36. package/dist/retry.js +254 -0
  37. package/dist/run.d.ts +10 -0
  38. package/dist/run.js +175 -0
  39. package/dist/stream.d.ts +21 -0
  40. package/dist/stream.js +84 -0
  41. package/dist/wait.d.ts +13 -0
  42. package/dist/wait.js +41 -0
  43. package/dist/watch.d.ts +16 -0
  44. package/dist/watch.js +277 -0
  45. package/package.json +47 -4
package/dist/client.js ADDED
@@ -0,0 +1,92 @@
1
+ import { create } from "@bufbuild/protobuf";
2
+ import { createClient } from "@connectrpc/connect";
3
+ import { executeCommand } from "./execute.js";
4
+ import { ArtifactService, ArtifactUploadService } from "./gen/toshi/workspace/v1/artifacts_pb.js";
5
+ import { ChannelService } from "./gen/toshi/workspace/v1/channels_pb.js";
6
+ import { DisplayService } from "./gen/toshi/workspace/v1/display_pb.js";
7
+ import { ProjectService } from "./gen/toshi/workspace/v1/projects_pb.js";
8
+ import { RecordingService } from "./gen/toshi/workspace/v1/recordings_pb.js";
9
+ import { SecretService } from "./gen/toshi/workspace/v1/secrets_pb.js";
10
+ import { VolumeService } from "./gen/toshi/workspace/v1/volumes_pb.js";
11
+ import { WebhookService } from "./gen/toshi/workspace/v1/webhook_pb.js";
12
+ import { CancelJobRequestSchema, CreateJobRequestSchema, CreateSandboxRequestSchema, CreateServiceRequestSchema, DeleteJobRequestSchema, DeleteSandboxRequestSchema, DeleteServiceRequestSchema, GetJobRequestSchema, GetSandboxRequestSchema, GetServiceRequestSchema, ListJobsRequestSchema, ListSandboxesRequestSchema, ListServicesRequestSchema, UpdateJobRequestSchema, UpdateSandboxRequestSchema, UpdateServiceRequestSchema, WaitJobRequestSchema, WaitSandboxRequestSchema, WaitServiceRequestSchema, WatchJobsRequestSchema, WatchSandboxesRequestSchema, WatchServicesRequestSchema, WorkloadService, } from "./gen/toshi/workspace/v1/workloads_pb.js";
13
+ import { ExecuteStartSchema, HoldServiceRequestSchema, ListServiceRunsRequestSchema, ReleaseServiceRequestSchema, SetServiceModeRequestSchema, StartServiceRequestSchema, StopServiceRequestSchema, WaitOperationRequestSchema, WakeServiceRequestSchema, WatchEventsRequestSchema, WatchOutputRequestSchema, } from "./gen/toshi/workspace/v1/workspace_pb.js";
14
+ import { openOutputWatch } from "./output.js";
15
+ import { authInterceptor, keyInterceptor, retryInterceptor, scopeInterceptor } from "./interceptors.js";
16
+ import { normalizeRetry } from "./retry.js";
17
+ import { openEventWatch, openJobWatch, openSandboxWatch, openServiceWatch } from "./watch.js";
18
+ import { runJob } from "./run.js";
19
+ import { waitJob, waitOperation, waitSandbox, waitService } from "./wait.js";
20
+ /**
21
+ * Creates the client: one normalized retry setting, the auth, scope, key and
22
+ * retry interceptors (auth outermost, retry innermost), one transport for every
23
+ * client and helper.
24
+ *
25
+ * Since 0.2.0 the public contract is WorkloadService, not WorkspaceService: its
26
+ * calls are grouped by the type they name under `workload`, and the generated
27
+ * client is not handed out whole.
28
+ */
29
+ export function createToshiClient(options) {
30
+ const retry = normalizeRetry(options.retry);
31
+ const interceptors = [
32
+ authInterceptor(options.token ?? ""),
33
+ scopeInterceptor(options.project ?? "", options.environment ?? ""),
34
+ keyInterceptor(),
35
+ retryInterceptor(retry),
36
+ ];
37
+ const transport = options.transport(interceptors);
38
+ const workloadService = createClient(WorkloadService, transport);
39
+ return {
40
+ project: createClient(ProjectService, transport),
41
+ secret: createClient(SecretService, transport),
42
+ webhook: createClient(WebhookService, transport),
43
+ display: createClient(DisplayService, transport),
44
+ channel: createClient(ChannelService, transport),
45
+ recording: createClient(RecordingService, transport),
46
+ volume: createClient(VolumeService, transport),
47
+ artifact: createClient(ArtifactService, transport),
48
+ artifactUpload: createClient(ArtifactUploadService, transport),
49
+ workload: {
50
+ jobs: {
51
+ create: (req, opts) => workloadService.createJob(req, opts),
52
+ get: (req, opts) => workloadService.getJob(req, opts),
53
+ list: (req, opts) => workloadService.listJobs(req, opts),
54
+ update: (req, opts) => workloadService.updateJob(req, opts),
55
+ delete: (req, opts) => workloadService.deleteJob(req, opts),
56
+ cancel: (req, opts) => workloadService.cancelJob(req, opts),
57
+ wait: (req, opts) => waitJob(workloadService, create(WaitJobRequestSchema, req), opts),
58
+ watch: (req, opts) => openJobWatch(workloadService, retry, create(WatchJobsRequestSchema, req), opts),
59
+ watchOutput: (req, opts) => openOutputWatch(workloadService, retry, create(WatchOutputRequestSchema, req), opts),
60
+ },
61
+ sandboxes: {
62
+ create: (req, opts) => workloadService.createSandbox(req, opts),
63
+ get: (req, opts) => workloadService.getSandbox(req, opts),
64
+ list: (req, opts) => workloadService.listSandboxes(req, opts),
65
+ update: (req, opts) => workloadService.updateSandbox(req, opts),
66
+ delete: (req, opts) => workloadService.deleteSandbox(req, opts),
67
+ wait: (req, opts) => waitSandbox(workloadService, create(WaitSandboxRequestSchema, req), opts),
68
+ watch: (req, opts) => openSandboxWatch(workloadService, retry, create(WatchSandboxesRequestSchema, req), opts),
69
+ execute: (start, opts) => executeCommand(workloadService, start, opts),
70
+ },
71
+ services: {
72
+ create: (req, opts) => workloadService.createService(req, opts),
73
+ get: (req, opts) => workloadService.getService(req, opts),
74
+ list: (req, opts) => workloadService.listServices(req, opts),
75
+ update: (req, opts) => workloadService.updateService(req, opts),
76
+ delete: (req, opts) => workloadService.deleteService(req, opts),
77
+ wait: (req, opts) => waitService(workloadService, create(WaitServiceRequestSchema, req), opts),
78
+ watch: (req, opts) => openServiceWatch(workloadService, retry, create(WatchServicesRequestSchema, req), opts),
79
+ start: (req, opts) => workloadService.startService(req, opts),
80
+ stop: (req, opts) => workloadService.stopService(req, opts),
81
+ setMode: (req, opts) => workloadService.setServiceMode(req, opts),
82
+ release: (req, opts) => workloadService.releaseService(req, opts),
83
+ wake: (req, opts) => workloadService.wakeService(req, opts),
84
+ hold: (req, opts) => workloadService.holdService(req, opts),
85
+ listRuns: (req, opts) => workloadService.listServiceRuns(req, opts),
86
+ },
87
+ waitOperation: (req, opts) => waitOperation(workloadService, create(WaitOperationRequestSchema, req), opts),
88
+ watchEvents: (req, opts) => openEventWatch(workloadService, retry, create(WatchEventsRequestSchema, req), opts),
89
+ },
90
+ run: (req, opts) => runJob(workloadService, retry, create(CreateJobRequestSchema, req), opts),
91
+ };
92
+ }
@@ -0,0 +1,28 @@
1
+ import type { ExecutionExit, OutputEnd } from "./gen/toshi/workspace/v1/workspace_pb.js";
2
+ /** A command that ended non-zero, or was cancelled before it had a status (errors.go:9-26). */
3
+ export declare class ExitError extends Error {
4
+ /** The execution the exit belongs to. */
5
+ readonly executionId: string;
6
+ /** The execution's exit message. */
7
+ readonly exit: ExecutionExit;
8
+ constructor(executionId: string, exit: ExecutionExit);
9
+ }
10
+ /** A job `run` started that did not exit 0. */
11
+ export declare class JobExitError extends Error {
12
+ /** The job's id. */
13
+ readonly jobId: string;
14
+ /** The job's end-of-output message. */
15
+ readonly end: OutputEnd;
16
+ constructor(jobId: string, end: OutputEnd);
17
+ }
18
+ /**
19
+ * An Execute stream that ended before its exit message; stdin cannot be replayed
20
+ * (errors.go:28-49). The cause is the error that ended the stream.
21
+ */
22
+ export declare class StreamLostError extends Error {
23
+ /** Empty when the stream was lost before the start was acknowledged. */
24
+ readonly executionId: string;
25
+ /** The request key of the start, so the caller may start again deliberately. */
26
+ readonly requestKey: string;
27
+ constructor(executionId: string, requestKey: string, cause?: unknown);
28
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,77 @@
1
+ /** A command that ended non-zero, or was cancelled before it had a status (errors.go:9-26). */
2
+ export class ExitError extends Error {
3
+ /** The execution the exit belongs to. */
4
+ executionId;
5
+ /** The execution's exit message. */
6
+ exit;
7
+ constructor(executionId, exit) {
8
+ super(exitMessage(executionId, exit));
9
+ this.name = "ExitError";
10
+ this.executionId = executionId;
11
+ this.exit = exit;
12
+ }
13
+ }
14
+ /** A job `run` started that did not exit 0. */
15
+ export class JobExitError extends Error {
16
+ /** The job's id. */
17
+ jobId;
18
+ /** The job's end-of-output message. */
19
+ end;
20
+ constructor(jobId, end) {
21
+ super(jobMessage(jobId, end));
22
+ this.name = "JobExitError";
23
+ this.jobId = jobId;
24
+ this.end = end;
25
+ }
26
+ }
27
+ function jobMessage(jobId, end) {
28
+ if (end.cancelled) {
29
+ return `toshi: job ${jobId} was cancelled`;
30
+ }
31
+ if (end.exitCode === undefined) {
32
+ return `toshi: job ${jobId} ended without an exit status`;
33
+ }
34
+ return `toshi: job ${jobId} exited with status ${end.exitCode}`;
35
+ }
36
+ function exitMessage(executionId, exit) {
37
+ if (exit.cancelled) {
38
+ return `toshi: execution ${executionId} was cancelled`;
39
+ }
40
+ if (exit.exitCode === undefined) {
41
+ return `toshi: execution ${executionId} ended without an exit status`;
42
+ }
43
+ return `toshi: execution ${executionId} exited with status ${exit.exitCode}`;
44
+ }
45
+ /**
46
+ * An Execute stream that ended before its exit message; stdin cannot be replayed
47
+ * (errors.go:28-49). The cause is the error that ended the stream.
48
+ */
49
+ export class StreamLostError extends Error {
50
+ /** Empty when the stream was lost before the start was acknowledged. */
51
+ executionId;
52
+ /** The request key of the start, so the caller may start again deliberately. */
53
+ requestKey;
54
+ constructor(executionId, requestKey, cause) {
55
+ super(lostMessage(executionId, requestKey, cause), cause === undefined ? undefined : { cause });
56
+ this.name = "StreamLostError";
57
+ this.executionId = executionId;
58
+ this.requestKey = requestKey;
59
+ }
60
+ }
61
+ function lostMessage(executionId, requestKey, cause) {
62
+ const detail = cause === undefined ? "" : `: ${describe(cause)}`;
63
+ if (executionId === "") {
64
+ return `toshi: execution stream lost before the start was acknowledged (request key ${requestKey})${detail}`;
65
+ }
66
+ return `toshi: execution ${executionId} stream lost (request key ${requestKey})${detail}`;
67
+ }
68
+ // The way Go's %v would print it, without stringifying an object as [object Object].
69
+ function describe(cause) {
70
+ if (cause instanceof Error) {
71
+ return cause.message;
72
+ }
73
+ if (typeof cause === "string" || typeof cause === "number" || typeof cause === "boolean") {
74
+ return String(cause);
75
+ }
76
+ return "the stream ended";
77
+ }
@@ -0,0 +1,7 @@
1
+ import type { MessageInitShape } from "@bufbuild/protobuf";
2
+ import type { Client } from "@connectrpc/connect";
3
+ import type { CallOptions, Process } from "./client.js";
4
+ import { ExecuteStartSchema } from "./gen/toshi/workspace/v1/workspace_pb.js";
5
+ import type { WorkloadService } from "./gen/toshi/workspace/v1/workloads_pb.js";
6
+ /** Starts a command and returns its process; the only export of this module. */
7
+ export declare function executeCommand(service: Client<typeof WorkloadService>, start: MessageInitShape<typeof ExecuteStartSchema> | undefined, opts?: CallOptions): Promise<Process>;
@@ -0,0 +1,380 @@
1
+ import { create } from "@bufbuild/protobuf";
2
+ import { Code, ConnectError } from "@connectrpc/connect";
3
+ import { createWritableIterable } from "@connectrpc/connect/protocol";
4
+ import { ExitError, StreamLostError } from "./errors.js";
5
+ import { isStreamLoss } from "./retry.js";
6
+ import { ExecuteRequestSchema, ExecuteStartSchema, } from "./gen/toshi/workspace/v1/workspace_pb.js";
7
+ // The largest standard-input frame Execute sends (execute.go:14-16).
8
+ const STDIN_CHUNK = 64 << 10;
9
+ const BROWSER_MESSAGE = 'toshi: Execute needs a Node transport over HTTP/2 (@connectrpc/connect-node with httpVersion "2"); a browser cannot open a bidirectional stream';
10
+ /** Starts a command and returns its process; the only export of this module. */
11
+ export async function executeCommand(service, start, opts) {
12
+ // G3: the browser check runs before any message or transport work, and reads
13
+ // process only through globalThis so src/ needs no Node import.
14
+ const nodeProcess = globalThis.process;
15
+ if (typeof nodeProcess?.versions?.node !== "string") {
16
+ throw new Error(BROWSER_MESSAGE);
17
+ }
18
+ if (start == null) {
19
+ throw new Error("toshi: Execute needs a start message");
20
+ }
21
+ const startMessage = create(ExecuteStartSchema, start);
22
+ const abort = new AbortController();
23
+ const signal = opts?.signal;
24
+ const requests = createWritableIterable();
25
+ const requestIterator = requests[Symbol.asyncIterator]();
26
+ const input = {
27
+ [Symbol.asyncIterator]: () => requestIterator,
28
+ };
29
+ // The handshake and, after it, the process both answer to the caller's signal.
30
+ const owner = {};
31
+ const onAbort = () => {
32
+ if (owner.process === undefined) {
33
+ abort.abort();
34
+ return;
35
+ }
36
+ owner.process.cancel();
37
+ };
38
+ // A signal that is already aborted starts nothing.
39
+ if (signal?.aborted === true) {
40
+ throw cancelledError();
41
+ }
42
+ signal?.addEventListener("abort", onAbort, { once: true });
43
+ const finishHandshake = () => {
44
+ signal?.removeEventListener("abort", onAbort);
45
+ };
46
+ const responses = service.execute(input, { signal: abort.signal });
47
+ const responseIterator = responses[Symbol.asyncIterator]();
48
+ // Send the start and read the answer together: the request stream only moves
49
+ // while the call is running. The created message is the one the interceptors
50
+ // fill with scope and key, as fillRequestKey does (execute.go:26-27).
51
+ const startRequest = {
52
+ message: { case: "start", value: startMessage },
53
+ };
54
+ let first;
55
+ try {
56
+ const firstRead = responseIterator.next();
57
+ void firstRead.catch(() => undefined);
58
+ // The write resolves only once the request stream is read past the frame,
59
+ // and a server may answer the start without reading again, so both are
60
+ // awaited together: the first answer is the start's acknowledgement.
61
+ const startSent = requests.write(startRequest);
62
+ first = await Promise.race([firstRead, startSent.then(() => firstRead)]);
63
+ // A failure after the first answer is the stream's loss, reported by the
64
+ // output reader, not by the start's write.
65
+ void startSent.catch(() => undefined);
66
+ }
67
+ catch (err) {
68
+ finishHandshake();
69
+ closeRequest(requestIterator, err);
70
+ closeResponse(responseIterator);
71
+ abort.abort();
72
+ // A caller's own abort is a cancellation, not a lost server stream.
73
+ if (signal !== undefined && signal.aborted) {
74
+ throw cancelledError();
75
+ }
76
+ throw beforeStarted(err, readRequestKey(startMessage));
77
+ }
78
+ const message = first.done === true ? undefined : first.value.message;
79
+ if (message === undefined || message.case !== "started") {
80
+ const failure = message === undefined
81
+ ? new StreamLostError("", readRequestKey(startMessage), new Error("toshi: the Execute stream ended before its started message"))
82
+ : new Error("toshi: the Execute stream did not answer with a started message");
83
+ finishHandshake();
84
+ closeRequest(requestIterator, failure);
85
+ closeResponse(responseIterator);
86
+ abort.abort();
87
+ throw failure;
88
+ }
89
+ const process = new ExecuteProcess(message.value.executionId, readRequestKey(startMessage), requests, requestIterator, responseIterator, abort, signal, onAbort);
90
+ owner.process = process;
91
+ return process;
92
+ }
93
+ function deferred() {
94
+ let settle = () => undefined;
95
+ const promise = new Promise((resolve) => {
96
+ settle = resolve;
97
+ });
98
+ return { promise, resolve: (value) => settle(value) };
99
+ }
100
+ function cancelledError() {
101
+ return new ConnectError("the call was cancelled", Code.Canceled);
102
+ }
103
+ /**
104
+ * Before started, a ConnectError is the server's answer and goes back
105
+ * unchanged; anything else is a lost stream (execute.go:30-46).
106
+ */
107
+ function beforeStarted(err, requestKey) {
108
+ if (err instanceof ConnectError && !isStreamLoss(err)) {
109
+ return err;
110
+ }
111
+ return new StreamLostError("", requestKey, err);
112
+ }
113
+ /** The key the start carried, including one the client's interceptor filled. */
114
+ function readRequestKey(start) {
115
+ return start.requestKey;
116
+ }
117
+ function closeRequest(iterator, reason) {
118
+ try {
119
+ void iterator.throw?.(reason)?.catch(() => undefined);
120
+ }
121
+ catch {
122
+ // the request stream is already gone
123
+ }
124
+ }
125
+ function closeResponse(iterator) {
126
+ try {
127
+ void iterator.return?.()?.catch(() => undefined);
128
+ }
129
+ catch {
130
+ // the response stream is already gone
131
+ }
132
+ }
133
+ /**
134
+ * One execution started by Execute. It reads the response only when its output
135
+ * is pulled or wait() drains it, so a stalled reader stalls the server's
136
+ * stream rather than buffering without bound.
137
+ */
138
+ class ExecuteProcess {
139
+ executionId;
140
+ requestKey;
141
+ requests;
142
+ requestIterator;
143
+ responses;
144
+ abort;
145
+ signal;
146
+ onSignalAbort;
147
+ terminalSignal = deferred();
148
+ queue = [];
149
+ terminal;
150
+ readerOwned = false;
151
+ waitPromise;
152
+ writeChain = Promise.resolve();
153
+ writing = false;
154
+ stdinClosed = false;
155
+ closePromise;
156
+ constructor(executionId, requestKey, requests, requestIterator, responses, abort, signal, onSignalAbort) {
157
+ this.executionId = executionId;
158
+ this.requestKey = requestKey;
159
+ this.requests = requests;
160
+ this.requestIterator = requestIterator;
161
+ this.responses = responses;
162
+ this.abort = abort;
163
+ this.signal = signal;
164
+ this.onSignalAbort = onSignalAbort;
165
+ if (signal?.aborted === true) {
166
+ this.cancel();
167
+ }
168
+ }
169
+ [Symbol.asyncIterator]() {
170
+ if (this.readerOwned) {
171
+ throw new Error("toshi: this process already has an output reader");
172
+ }
173
+ this.readerOwned = true;
174
+ return {
175
+ next: () => this.pull(),
176
+ // A break aborts the local stream; wait() then reports Canceled.
177
+ return: () => {
178
+ this.cancel();
179
+ return Promise.resolve({ done: true, value: undefined });
180
+ },
181
+ };
182
+ }
183
+ /**
184
+ * Sends stdin in frames of at most 64 KiB and resolves once the last frame
185
+ * was consumed. One write at a time: a write while another is pending is
186
+ * refused, so the client holds at most one caller buffer and one frame.
187
+ */
188
+ write(data) {
189
+ if (this.terminal !== undefined) {
190
+ return Promise.reject(this.closedReason());
191
+ }
192
+ if (this.stdinClosed) {
193
+ return Promise.reject(new Error("toshi: standard input is closed"));
194
+ }
195
+ if (this.writing) {
196
+ return Promise.reject(new Error("toshi: a write is still pending; await it before writing again"));
197
+ }
198
+ this.writing = true;
199
+ const run = (typeof data === "string" ? this.sendText(data) : this.sendFrames(data)).finally(() => {
200
+ this.writing = false;
201
+ });
202
+ this.writeChain = run.catch(() => undefined);
203
+ return run;
204
+ }
205
+ /** Sends close_stdin once, after a pending write; later writes are refused (execute.go:243-256). */
206
+ closeStdin() {
207
+ if (this.closePromise !== undefined) {
208
+ return this.closePromise;
209
+ }
210
+ this.stdinClosed = true;
211
+ const run = this.writeChain.then(() => {
212
+ if (this.terminal !== undefined) {
213
+ return;
214
+ }
215
+ // Not awaited: the frame is consumed only when the request stream is read
216
+ // past it, and the server stops reading after close_stdin.
217
+ const sent = this.requests.write({ message: { case: "closeStdin", value: true } });
218
+ void sent.catch(() => undefined);
219
+ });
220
+ this.closePromise = run;
221
+ return run;
222
+ }
223
+ /** The process's outcome; the same promise on every call. */
224
+ wait() {
225
+ this.waitPromise ??= this.awaitOutcome();
226
+ return this.waitPromise;
227
+ }
228
+ /** Ends the process as cancelled: early break and an aborted call signal. */
229
+ cancel() {
230
+ this.finish({ kind: "cancelled", error: cancelledError() });
231
+ }
232
+ async sendFrames(bytes) {
233
+ for (let offset = 0; offset < bytes.length; offset += STDIN_CHUNK) {
234
+ const end = Math.min(offset + STDIN_CHUNK, bytes.length);
235
+ // Copied, as the sender copies each chunk (execute.go:227).
236
+ const frame = bytes.slice(offset, end);
237
+ await this.requests.write({ message: { case: "stdin", value: frame } });
238
+ }
239
+ }
240
+ // Encodes one frame at a time, so a long string is never copied whole.
241
+ async sendText(text) {
242
+ const encoder = new TextEncoder();
243
+ let rest = text;
244
+ while (rest.length > 0) {
245
+ const frame = new Uint8Array(STDIN_CHUNK);
246
+ const { read, written } = encoder.encodeInto(rest, frame);
247
+ rest = rest.slice(read);
248
+ await this.requests.write({ message: { case: "stdin", value: frame.subarray(0, written) } });
249
+ }
250
+ }
251
+ async awaitOutcome() {
252
+ if (!this.readerOwned && this.terminal === undefined) {
253
+ // No reader owns the output: take it and drain it, so wait() never stalls on unread output.
254
+ this.readerOwned = true;
255
+ try {
256
+ for (;;) {
257
+ const result = await this.pull();
258
+ if (result.done === true) {
259
+ break;
260
+ }
261
+ }
262
+ }
263
+ catch {
264
+ // pull() recorded the terminal outcome; it is reported below.
265
+ }
266
+ }
267
+ await this.terminalSignal.promise;
268
+ const terminal = this.terminal;
269
+ if (terminal === undefined) {
270
+ throw cancelledError();
271
+ }
272
+ switch (terminal.kind) {
273
+ case "exit":
274
+ if (terminal.error !== undefined) {
275
+ throw terminal.error;
276
+ }
277
+ return;
278
+ default:
279
+ throw terminal.error;
280
+ }
281
+ }
282
+ async pull() {
283
+ for (;;) {
284
+ const chunk = this.queue.shift();
285
+ if (chunk !== undefined) {
286
+ return { done: false, value: chunk };
287
+ }
288
+ if (this.terminal !== undefined) {
289
+ return this.terminalResult();
290
+ }
291
+ await this.readOne();
292
+ }
293
+ }
294
+ terminalResult() {
295
+ const terminal = this.terminal;
296
+ if (terminal === undefined || terminal.kind === "exit") {
297
+ return { done: true, value: undefined };
298
+ }
299
+ throw terminal.error;
300
+ }
301
+ async readOne() {
302
+ const next = this.responses.next();
303
+ // Losing the race must not leave an unhandled rejection behind: both
304
+ // branches below are attached before the race settles.
305
+ const raced = await Promise.race([
306
+ next.then((result) => ({ kind: "message", result }), (error) => ({ kind: "error", error })),
307
+ this.terminalSignal.promise.then(() => ({ kind: "terminal" })),
308
+ ]);
309
+ if (raced.kind === "terminal") {
310
+ return;
311
+ }
312
+ if (raced.kind === "error") {
313
+ // After started, every end before the exit message is a lost stream
314
+ // (execute.go:113-118).
315
+ this.finish({ kind: "lost", error: this.lost(raced.error) });
316
+ return;
317
+ }
318
+ if (raced.result.done === true) {
319
+ this.finish({
320
+ kind: "lost",
321
+ error: this.lost(new Error("toshi: the Execute stream ended without an exit message")),
322
+ });
323
+ return;
324
+ }
325
+ this.handle(raced.result.value);
326
+ }
327
+ handle(response) {
328
+ const message = response.message;
329
+ switch (message.case) {
330
+ case "stdout":
331
+ this.queue.push({ stream: "stdout", data: message.value });
332
+ return;
333
+ case "stderr":
334
+ this.queue.push({ stream: "stderr", data: message.value });
335
+ return;
336
+ case "exit":
337
+ this.finishExit(message.value);
338
+ return;
339
+ default:
340
+ // A repeated started is ignored, as the receive switch does (execute.go:107-127).
341
+ return;
342
+ }
343
+ }
344
+ lost(cause) {
345
+ return new StreamLostError(this.executionId, this.requestKey, cause);
346
+ }
347
+ finishExit(exit) {
348
+ // Only a zero exit code is success; an absent one never is (execute.go:197-204).
349
+ const ok = exit.exitCode === 0;
350
+ this.finish({
351
+ kind: "exit",
352
+ exit,
353
+ error: ok ? undefined : new ExitError(this.executionId, exit),
354
+ });
355
+ }
356
+ finish(terminal) {
357
+ if (this.terminal !== undefined) {
358
+ return;
359
+ }
360
+ this.terminal = terminal;
361
+ this.terminalSignal.resolve();
362
+ if (this.signal !== undefined) {
363
+ this.signal.removeEventListener("abort", this.onSignalAbort);
364
+ }
365
+ // Closing the request side rejects pending writes (execute.go:131-151).
366
+ closeRequest(this.requestIterator, this.closedReason());
367
+ this.abort.abort();
368
+ closeResponse(this.responses);
369
+ }
370
+ closedReason() {
371
+ const terminal = this.terminal;
372
+ if (terminal === undefined) {
373
+ return cancelledError();
374
+ }
375
+ if (terminal.kind === "exit") {
376
+ return terminal.error ?? new Error("toshi: the execution has ended");
377
+ }
378
+ return terminal.error;
379
+ }
380
+ }