@toshicloud/sdk 0.0.0-stage → 0.2.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 (39) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +201 -2
  3. package/dist/client.d.ts +78 -0
  4. package/dist/client.js +42 -0
  5. package/dist/errors.d.ts +20 -0
  6. package/dist/errors.js +55 -0
  7. package/dist/execute.d.ts +6 -0
  8. package/dist/execute.js +380 -0
  9. package/dist/gen/toshi/workspace/v1/artifacts_pb.d.ts +841 -0
  10. package/dist/gen/toshi/workspace/v1/artifacts_pb.js +96 -0
  11. package/dist/gen/toshi/workspace/v1/display_pb.d.ts +1142 -0
  12. package/dist/gen/toshi/workspace/v1/display_pb.js +214 -0
  13. package/dist/gen/toshi/workspace/v1/projects_pb.d.ts +1386 -0
  14. package/dist/gen/toshi/workspace/v1/projects_pb.js +207 -0
  15. package/dist/gen/toshi/workspace/v1/recordings_pb.d.ts +948 -0
  16. package/dist/gen/toshi/workspace/v1/recordings_pb.js +201 -0
  17. package/dist/gen/toshi/workspace/v1/secrets_pb.d.ts +1062 -0
  18. package/dist/gen/toshi/workspace/v1/secrets_pb.js +182 -0
  19. package/dist/gen/toshi/workspace/v1/volumes_pb.d.ts +406 -0
  20. package/dist/gen/toshi/workspace/v1/volumes_pb.js +107 -0
  21. package/dist/gen/toshi/workspace/v1/webhook_pb.d.ts +974 -0
  22. package/dist/gen/toshi/workspace/v1/webhook_pb.js +153 -0
  23. package/dist/gen/toshi/workspace/v1/workspace_pb.d.ts +6133 -0
  24. package/dist/gen/toshi/workspace/v1/workspace_pb.js +892 -0
  25. package/dist/index.d.ts +10 -0
  26. package/dist/index.js +9 -0
  27. package/dist/interceptors.d.ts +10 -0
  28. package/dist/interceptors.js +159 -0
  29. package/dist/output.d.ts +6 -0
  30. package/dist/output.js +77 -0
  31. package/dist/retry.d.ts +42 -0
  32. package/dist/retry.js +213 -0
  33. package/dist/stream.d.ts +21 -0
  34. package/dist/stream.js +84 -0
  35. package/dist/wait.d.ts +8 -0
  36. package/dist/wait.js +29 -0
  37. package/dist/watch.d.ts +8 -0
  38. package/dist/watch.js +224 -0
  39. package/package.json +47 -4
@@ -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, WorkspaceService, } 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(workspace, 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 = workspace.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
+ }