@toshicloud/sdk 0.3.2 → 0.4.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/README.md CHANGED
@@ -4,40 +4,26 @@ A TypeScript client for the Toshi Workspace API, `toshi.workspace.v1`,
4
4
  published as ES modules with type declarations.
5
5
 
6
6
  ```sh
7
- npm install @toshicloud/sdk @connectrpc/connect @connectrpc/connect-node
7
+ npm install @toshicloud/sdk @connectrpc/connect
8
8
  ```
9
9
 
10
+ The browser entry needs a transport such as `@connectrpc/connect-web`; the
11
+ Node entry needs `@connectrpc/connect-node`. Both are peer dependencies, so
12
+ install the ones your application uses.
13
+
10
14
  Versions up to 0.1.2 were published as `@toshicloud/toshi-ts`.
11
15
 
12
16
  ## Using the client
13
17
 
14
- `createToshiClient` takes one set of options. The transport belongs to
15
- the caller: the client hands its interceptors to the `transport`
16
- function, and that function builds a transport with them. Every call then
17
- gets the same token, scope and retries: the helpers below and the
18
- generated clients alike.
19
-
20
- In Node, with `@connectrpc/connect-node`:
18
+ In Node, `@toshicloud/sdk/node` builds the transport and fills the server
19
+ and the token from the environment:
21
20
 
22
21
  <!-- examples/quick-start.ts -->
23
22
 
24
23
  ```ts
25
- import { readFileSync } from "node:fs";
26
- import { createConnectTransport } from "@connectrpc/connect-node";
27
- import { createToshiClient } from "@toshicloud/sdk";
24
+ import { createToshiClient } from "@toshicloud/sdk/node";
28
25
 
29
- const toshi = createToshiClient({
30
- transport: (interceptors) =>
31
- createConnectTransport({
32
- baseUrl: "https://toshi.example.com",
33
- httpVersion: "2",
34
- interceptors,
35
- // Only for a server whose certificate a private CA signed.
36
- nodeOptions: { ca: readFileSync("ca.pem") },
37
- }),
38
- token: process.env.TOSHI_TOKEN,
39
- project: "default",
40
- });
26
+ const toshi = createToshiClient({ project: "default" });
41
27
 
42
28
  const { projects } = await toshi.project.listProjects({});
43
29
  console.log(projects.length);
@@ -62,14 +48,21 @@ export const toshi = createToshiClient({
62
48
  });
63
49
  ```
64
50
 
65
- To add interceptors of your own, put them before the client's:
66
- `interceptors: [...mine, ...interceptors]`.
51
+ Interceptors of your own go before the client's. The Node entry takes
52
+ `interceptors`; with a transport of your own, put them first:
53
+ `interceptors: [...mine, ...interceptors]`. Each unary call enters them
54
+ once, with its retries inside, and they never see the Authorization
55
+ header: the client sets it on a copy of the request below them.
67
56
 
68
- The package reads no environment variable and no file. The token, the
69
- server and any CA come from the caller.
57
+ The browser entry reads no environment variable and no file. The Node
58
+ entry reads `TOSHI_SERVER`, `TOSHI_TOKEN`, `TOSHI_TOKEN_FILE` and
59
+ `TOSHI_CA_FILE`; see Node options.
70
60
 
71
61
  ### Options
72
62
 
63
+ - `tokenSource`: a function that gives each request attempt its token, so a
64
+ refreshed token is used without a new client; it wins over `token`. See
65
+ Tokens that change.
73
66
  - `token`: sent as `Authorization: Bearer <token>` on every call.
74
67
  - `project`, `environment`: fill the request's `project` and
75
68
  `environment` fields when they are empty. A value in the request wins.
@@ -77,6 +70,65 @@ server and any CA come from the caller.
77
70
  - `retry`: `{ maxAttempts, initialBackoffMs, maxBackoffMs }`, by default
78
71
  5, 200 ms and 10 s, with full jitter. `retry: false` turns off retries
79
72
  and watch reconnects.
73
+ - `defaultTimeoutMs`: bounds each unary call except the Wait calls,
74
+ which their request's timeout bounds; streams ignore it.
75
+
76
+ A call may set `timeoutMs` instead: it bounds the whole call, retries
77
+ included, and on a watch it bounds the open. It composes with `signal`.
78
+
79
+ ### Node options
80
+
81
+ `@toshicloud/sdk/node` builds a connect-node transport over HTTP/2 and
82
+ fills what is missing from the environment:
83
+
84
+ - `server`, else `TOSHI_SERVER`: the control plane's URL. Without either,
85
+ the client refuses to start.
86
+ - `tokenSource`, `token`, `tokenFile`, `TOSHI_TOKEN`, `TOSHI_TOKEN_FILE`, in
87
+ that order. A token file is trimmed, and an empty file is an error.
88
+ - `caFile`, else `TOSHI_CA_FILE`: a PEM bundle trusted besides Node's
89
+ built-in roots.
90
+ - `userAgent`: put in front of the User-Agent, for example "myapp/1.2".
91
+ - `interceptors`: run before the client's own, in the order given.
92
+ - `defaultTimeoutMs`: bounds each unary call except the Wait calls,
93
+ which their request's timeout bounds; streams ignore it.
94
+ - `transport`: a transport of your own; then `server` and `caFile` are not
95
+ required.
96
+
97
+ Every request carries `User-Agent: toshi-ts/<version> (node/<node
98
+ version>)`, with `userAgent` and one space in front when it is set. The
99
+ root entry sends no User-Agent: browsers own it.
100
+
101
+ ### Tokens that change
102
+
103
+ `tokenSource` gives the client a function it calls for the token of every
104
+ request attempt, so a token that is refreshed, for example from a secret
105
+ manager, is used without a new client. It wins over every other token
106
+ option. The client asks it once per attempt, a retry included, and may
107
+ ask it concurrently, so it must be fast: keep the token somewhere and
108
+ refresh it outside the call. A source that throws, or that answers an
109
+ empty token, fails the call with `UNAUTHENTICATED`, with the error as the
110
+ cause, and the call is not retried.
111
+
112
+ <!-- examples/token-source.ts -->
113
+
114
+ ```ts
115
+ // A token a refresh loop replaces; the client asks for it on every call.
116
+ let token = "first-token";
117
+
118
+ export const toshi = createToshiClient({
119
+ project: "default",
120
+ tokenSource: () => token,
121
+ });
122
+
123
+ export function rotate(next: string): void {
124
+ token = next;
125
+ }
126
+ ```
127
+
128
+ A token file, from `tokenFile` or `TOSHI_TOKEN_FILE`, is read again
129
+ before a request when its modification time or size has changed, so a
130
+ rotated token, such as a Kubernetes projected service account token, is
131
+ picked up without a new client.
80
132
 
81
133
  ### The client's shape
82
134
 
@@ -102,6 +154,43 @@ const { job } = await toshi.workload.jobs.get({ id: jobs[0].id });
102
154
  console.log(job?.id);
103
155
  ```
104
156
 
157
+ ### Lists
158
+
159
+ `workload.jobs.all`, `workload.sandboxes.all`, `workload.services.all`,
160
+ `workload.services.allRuns` and `workload.allEvents` take the request their
161
+ `list` method takes and yield every item, one page after another. Each
162
+ response's `nextPageToken` becomes the next request's `pageToken`, and a
163
+ `pageToken` in the request resumes from that page. A service with no
164
+ `all` method is one `paginate` call away: it takes the list call and a
165
+ function that picks a response's items.
166
+
167
+ <!-- examples/lists.ts -->
168
+
169
+ ```ts
170
+ import { paginate } from "@toshicloud/sdk";
171
+
172
+ import { toshi } from "./client.js";
173
+
174
+ // Every job that matches the request, one page after another.
175
+ for await (const job of toshi.workload.jobs.all({ pageSize: 10 })) {
176
+ console.log(job.id);
177
+ }
178
+
179
+ // A service with no all() method: paginate takes its list call and items.
180
+ for await (const project of paginate(
181
+ {},
182
+ (req, opts) => toshi.project.listProjects(req, opts),
183
+ (resp) => resp.projects,
184
+ )) {
185
+ console.log(project.name);
186
+ }
187
+ ```
188
+
189
+ `paginate` sends a copy of the request, so the caller's request keeps its
190
+ fields. Every page is a call of its own, so `opts` goes to each one, and
191
+ `timeoutMs` bounds a page and not the whole iteration. An error throws once
192
+ and ends the iteration; leaving the loop sends no further request.
193
+
105
194
  ### Retries
106
195
 
107
196
  The client retries a read, and a write that carries a request key, when
@@ -216,6 +305,35 @@ await run.wait(); // rejects with JobExitError on a non-zero exit
216
305
  Stopping the loop stops only the output stream: the job keeps running,
217
306
  and `workload.jobs.cancel` ends it.
218
307
 
308
+ ## Testing with `@toshicloud/sdk/test`
309
+
310
+ `createFake()` serves the whole Workspace API in memory: no server, no port,
311
+ nothing to close, and no Node module, so it runs in the same runtimes as the
312
+ client. `fake.client()` returns a `ToshiClient` wired to it, with the token
313
+ `test-token` and the scope `default`/`production`, which is enough to drive
314
+ `run`, the watchers and the generated clients from a test.
315
+
316
+ <!-- examples/testing.ts -->
317
+
318
+ ```ts
319
+ import { createFake } from "@toshicloud/sdk/test";
320
+
321
+ const fake = createFake();
322
+ fake.job("job-1").stdout("deployed\n").exitCode(0);
323
+
324
+ const toshi = fake.client();
325
+ const run = await toshi.run({ spec: { command: { argv: ["./deploy.sh"] } } });
326
+ for await (const chunk of run) {
327
+ process[chunk.stream].write(chunk.data);
328
+ }
329
+ await run.wait();
330
+ ```
331
+
332
+ `fake.on(method, impl)` replaces the state machine for one method with a hook
333
+ typed by that method: one call for unary, server-stream and bidirectional
334
+ methods alike. `fake.calls()` returns every call the fake received, oldest
335
+ first, with the procedure, the request and the headers.
336
+
219
337
  ## Generated code
220
338
 
221
339
  `src/gen/toshi/workspace/v1/` is generated by
package/dist/client.d.ts CHANGED
@@ -18,21 +18,32 @@ import { WorkspaceService } from "./gen/toshi/workspace/v1/workspace_service_pb.
18
18
  import { CancelJobRequestSchema, CreateJobRequestSchema, CreateSandboxRequestSchema, CreateServiceRequestSchema, DeleteJobRequestSchema, DeleteSandboxRequestSchema, DeleteServiceRequestSchema, GetJobRequestSchema, GetSandboxRequestSchema, GetServiceRequestSchema, ListJobsRequestSchema, ListSandboxesRequestSchema, ListServicesRequestSchema, UpdateJobRequestSchema, UpdateSandboxRequestSchema, UpdateServiceRequestSchema, WaitJobRequestSchema, WaitSandboxRequestSchema, WaitServiceRequestSchema, WatchJobsRequestSchema, WatchSandboxesRequestSchema, WatchServicesRequestSchema } from "./gen/toshi/workspace/v1/workloads_pb.js";
19
19
  import type { CancelJobResponse, CreateJobResponse, CreateSandboxResponse, CreateServiceResponse, DeleteJobResponse, DeleteSandboxResponse, DeleteServiceResponse, GetJobResponse, GetSandboxResponse, GetServiceResponse, ListJobsResponse, ListSandboxesResponse, ListServicesResponse, UpdateJobResponse, UpdateSandboxResponse, UpdateServiceResponse, WaitJobResponse, WaitSandboxResponse, WaitServiceResponse, WatchJobsResponse, WatchSandboxesResponse, WatchServicesResponse } from "./gen/toshi/workspace/v1/workloads_pb.js";
20
20
  import { ExecuteStartSchema, GetOperationRequestSchema, HoldServiceRequestSchema, ListEventsRequestSchema, ListServiceRunsRequestSchema, ReleaseServiceRequestSchema, SetServiceModeRequestSchema, StartServiceRequestSchema, StopServiceRequestSchema, WaitOperationRequestSchema, WakeServiceRequestSchema, WatchEventsRequestSchema, WatchOutputRequestSchema } from "./gen/toshi/workspace/v1/workspace_pb.js";
21
- import type { GetOperationResponse, HoldServiceResponse, Job, ListEventsResponse, ListServiceRunsResponse, Operation, ReleaseServiceResponse, Sandbox, Service, SetServiceModeResponse, StartServiceResponse, StopServiceResponse, WakeServiceResponse, WatchEventsResponse, WatchOutputResponse } from "./gen/toshi/workspace/v1/workspace_pb.js";
21
+ import type { Event, GetOperationResponse, HoldServiceResponse, Job, ListEventsResponse, ListServiceRunsResponse, Operation, ReleaseServiceResponse, Sandbox, Service, ServiceRun, SetServiceModeResponse, StartServiceResponse, StopServiceResponse, WakeServiceResponse, WatchEventsResponse, WatchOutputResponse } from "./gen/toshi/workspace/v1/workspace_pb.js";
22
22
  import type { RetryPolicy } from "./retry.js";
23
23
  /** How the client is configured; the caller builds the transport. */
24
24
  export interface ToshiOptions {
25
25
  /** Builds the caller's transport (connect-node or connect-web) with the client's interceptors. */
26
26
  transport: (interceptors: Interceptor[]) => Transport;
27
27
  token?: string;
28
+ /**
29
+ * Gives each request attempt its token, so a refreshed token is used without
30
+ * a new client; it wins over `token` and a retry asks it again. A source that
31
+ * throws or answers an empty token ends the call with Code.Unauthenticated,
32
+ * with its error as the cause, and the call is not retried.
33
+ */
34
+ tokenSource?: (signal: AbortSignal | undefined) => string | Promise<string>;
28
35
  project?: string;
29
36
  environment?: string;
30
37
  /** false turns off retries and watch reconnects. */
31
38
  retry?: Partial<RetryPolicy> | false;
39
+ /** Bounds each unary call except the Wait calls, which their request's timeout bounds; streams ignore it. */
40
+ defaultTimeoutMs?: number;
32
41
  }
33
42
  /** Per-call options. */
34
43
  export interface CallOptions {
35
44
  signal?: AbortSignal;
45
+ /** Bounds the whole call, retries included; on a watch, the open. */
46
+ timeoutMs?: number;
36
47
  }
37
48
  /** One client per Workspace API service, plus the helpers that live on top of them. */
38
49
  export interface ToshiClient {
@@ -63,6 +74,8 @@ export interface WorkloadClient {
63
74
  waitOperation(req: MessageInitShape<typeof WaitOperationRequestSchema>, opts?: CallOptions): Promise<Operation>;
64
75
  /** Lists the events after a cursor, a page at a time; `watchEvents` is the live form. */
65
76
  listEvents(req: MessageInitShape<typeof ListEventsRequestSchema>, opts?: CallOptions): Promise<ListEventsResponse>;
77
+ /** Yields every event listEvents lists, following the page token from one page to the next. */
78
+ allEvents(req: MessageInitShape<typeof ListEventsRequestSchema>, opts?: CallOptions): AsyncIterable<Event>;
66
79
  watchEvents(req: MessageInitShape<typeof WatchEventsRequestSchema>, opts?: CallOptions): Promise<EventWatch>;
67
80
  /** Returns one operation: the id a long call hands back, asked for again. */
68
81
  getOperation(req: MessageInitShape<typeof GetOperationRequestSchema>, opts?: CallOptions): Promise<GetOperationResponse>;
@@ -72,6 +85,8 @@ export interface JobsClient {
72
85
  create(req: MessageInitShape<typeof CreateJobRequestSchema>, opts?: CallOptions): Promise<CreateJobResponse>;
73
86
  get(req: MessageInitShape<typeof GetJobRequestSchema>, opts?: CallOptions): Promise<GetJobResponse>;
74
87
  list(req: MessageInitShape<typeof ListJobsRequestSchema>, opts?: CallOptions): Promise<ListJobsResponse>;
88
+ /** Yields every job that matches every filter that is set, following the page token from one page to the next. */
89
+ all(req: MessageInitShape<typeof ListJobsRequestSchema>, opts?: CallOptions): AsyncIterable<Job>;
75
90
  update(req: MessageInitShape<typeof UpdateJobRequestSchema>, opts?: CallOptions): Promise<UpdateJobResponse>;
76
91
  delete(req: MessageInitShape<typeof DeleteJobRequestSchema>, opts?: CallOptions): Promise<DeleteJobResponse>;
77
92
  cancel(req: MessageInitShape<typeof CancelJobRequestSchema>, opts?: CallOptions): Promise<CancelJobResponse>;
@@ -84,6 +99,8 @@ export interface SandboxesClient {
84
99
  create(req: MessageInitShape<typeof CreateSandboxRequestSchema>, opts?: CallOptions): Promise<CreateSandboxResponse>;
85
100
  get(req: MessageInitShape<typeof GetSandboxRequestSchema>, opts?: CallOptions): Promise<GetSandboxResponse>;
86
101
  list(req: MessageInitShape<typeof ListSandboxesRequestSchema>, opts?: CallOptions): Promise<ListSandboxesResponse>;
102
+ /** Yields every sandbox that matches every filter that is set, following the page token from one page to the next. */
103
+ all(req: MessageInitShape<typeof ListSandboxesRequestSchema>, opts?: CallOptions): AsyncIterable<Sandbox>;
87
104
  update(req: MessageInitShape<typeof UpdateSandboxRequestSchema>, opts?: CallOptions): Promise<UpdateSandboxResponse>;
88
105
  delete(req: MessageInitShape<typeof DeleteSandboxRequestSchema>, opts?: CallOptions): Promise<DeleteSandboxResponse>;
89
106
  wait(req: MessageInitShape<typeof WaitSandboxRequestSchema>, opts?: CallOptions): Promise<WaitSandboxResponse>;
@@ -95,6 +112,8 @@ export interface ServicesClient {
95
112
  create(req: MessageInitShape<typeof CreateServiceRequestSchema>, opts?: CallOptions): Promise<CreateServiceResponse>;
96
113
  get(req: MessageInitShape<typeof GetServiceRequestSchema>, opts?: CallOptions): Promise<GetServiceResponse>;
97
114
  list(req: MessageInitShape<typeof ListServicesRequestSchema>, opts?: CallOptions): Promise<ListServicesResponse>;
115
+ /** Yields every service that matches every filter that is set, following the page token from one page to the next. */
116
+ all(req: MessageInitShape<typeof ListServicesRequestSchema>, opts?: CallOptions): AsyncIterable<Service>;
98
117
  update(req: MessageInitShape<typeof UpdateServiceRequestSchema>, opts?: CallOptions): Promise<UpdateServiceResponse>;
99
118
  delete(req: MessageInitShape<typeof DeleteServiceRequestSchema>, opts?: CallOptions): Promise<DeleteServiceResponse>;
100
119
  wait(req: MessageInitShape<typeof WaitServiceRequestSchema>, opts?: CallOptions): Promise<WaitServiceResponse>;
@@ -106,6 +125,8 @@ export interface ServicesClient {
106
125
  wake(req: MessageInitShape<typeof WakeServiceRequestSchema>, opts?: CallOptions): Promise<WakeServiceResponse>;
107
126
  hold(req: MessageInitShape<typeof HoldServiceRequestSchema>, opts?: CallOptions): Promise<HoldServiceResponse>;
108
127
  listRuns(req: MessageInitShape<typeof ListServiceRunsRequestSchema>, opts?: CallOptions): Promise<ListServiceRunsResponse>;
128
+ /** Yields every run listRuns returns, the newest first, following the page token from one page to the next. */
129
+ allRuns(req: MessageInitShape<typeof ListServiceRunsRequestSchema>, opts?: CallOptions): AsyncIterable<ServiceRun>;
109
130
  }
110
131
  /** A live stream of a job's changes. */
111
132
  export interface JobWatch extends AsyncIterable<WatchJobsResponse> {
@@ -160,9 +181,9 @@ export interface JobRun extends AsyncIterable<OutputChunk> {
160
181
  wait(): Promise<void>;
161
182
  }
162
183
  /**
163
- * Creates the client: one normalized retry setting, the auth, scope, key and
164
- * retry interceptors (auth outermost, retry innermost), one transport for every
165
- * client and helper.
184
+ * Creates the client: one normalized retry setting, the scope, key, retry and
185
+ * auth interceptors (scope outermost, auth innermost, so no interceptor sees
186
+ * the Authorization header), one transport for every client and helper.
166
187
  *
167
188
  * Since 0.2.0 the workload calls are grouped by the type they name under
168
189
  * `workload`; every other service, WorkspaceService's included, is handed out
package/dist/client.js CHANGED
@@ -19,15 +19,17 @@ import { WorkspaceService } from "./gen/toshi/workspace/v1/workspace_service_pb.
19
19
  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";
20
20
  import { ExecuteStartSchema, GetOperationRequestSchema, HoldServiceRequestSchema, ListEventsRequestSchema, ListServiceRunsRequestSchema, ReleaseServiceRequestSchema, SetServiceModeRequestSchema, StartServiceRequestSchema, StopServiceRequestSchema, WaitOperationRequestSchema, WakeServiceRequestSchema, WatchEventsRequestSchema, WatchOutputRequestSchema, } from "./gen/toshi/workspace/v1/workspace_pb.js";
21
21
  import { openOutputWatch } from "./output.js";
22
+ import { paginate } from "./paginate.js";
22
23
  import { authInterceptor, keyInterceptor, retryInterceptor, scopeInterceptor } from "./interceptors.js";
23
24
  import { normalizeRetry } from "./retry.js";
25
+ import { withDefaultUnaryTimeout } from "./timeout.js";
24
26
  import { openEventWatch, openJobWatch, openSandboxWatch, openServiceWatch } from "./watch.js";
25
27
  import { runJob } from "./run.js";
26
28
  import { waitJob, waitOperation, waitSandbox, waitService } from "./wait.js";
27
29
  /**
28
- * Creates the client: one normalized retry setting, the auth, scope, key and
29
- * retry interceptors (auth outermost, retry innermost), one transport for every
30
- * client and helper.
30
+ * Creates the client: one normalized retry setting, the scope, key, retry and
31
+ * auth interceptors (scope outermost, auth innermost, so no interceptor sees
32
+ * the Authorization header), one transport for every client and helper.
31
33
  *
32
34
  * Since 0.2.0 the workload calls are grouped by the type they name under
33
35
  * `workload`; every other service, WorkspaceService's included, is handed out
@@ -36,12 +38,12 @@ import { waitJob, waitOperation, waitSandbox, waitService } from "./wait.js";
36
38
  export function createToshiClient(options) {
37
39
  const retry = normalizeRetry(options.retry);
38
40
  const interceptors = [
39
- authInterceptor(options.token ?? ""),
40
41
  scopeInterceptor(options.project ?? "", options.environment ?? ""),
41
42
  keyInterceptor(),
42
43
  retryInterceptor(retry),
44
+ authInterceptor(options.token ?? "", options.tokenSource),
43
45
  ];
44
- const transport = options.transport(interceptors);
46
+ const transport = withDefaultUnaryTimeout(options.transport(interceptors), options.defaultTimeoutMs);
45
47
  const workloadService = createClient(WorkloadService, transport);
46
48
  return {
47
49
  project: createClient(ProjectService, transport),
@@ -58,6 +60,7 @@ export function createToshiClient(options) {
58
60
  create: (req, opts) => workloadService.createJob(req, opts),
59
61
  get: (req, opts) => workloadService.getJob(req, opts),
60
62
  list: (req, opts) => workloadService.listJobs(req, opts),
63
+ all: (req, opts) => paginate(req, workloadService.listJobs, (resp) => resp.jobs, opts),
61
64
  update: (req, opts) => workloadService.updateJob(req, opts),
62
65
  delete: (req, opts) => workloadService.deleteJob(req, opts),
63
66
  cancel: (req, opts) => workloadService.cancelJob(req, opts),
@@ -69,6 +72,7 @@ export function createToshiClient(options) {
69
72
  create: (req, opts) => workloadService.createSandbox(req, opts),
70
73
  get: (req, opts) => workloadService.getSandbox(req, opts),
71
74
  list: (req, opts) => workloadService.listSandboxes(req, opts),
75
+ all: (req, opts) => paginate(req, workloadService.listSandboxes, (resp) => resp.sandboxes, opts),
72
76
  update: (req, opts) => workloadService.updateSandbox(req, opts),
73
77
  delete: (req, opts) => workloadService.deleteSandbox(req, opts),
74
78
  wait: (req, opts) => waitSandbox(workloadService, create(WaitSandboxRequestSchema, req), opts),
@@ -79,6 +83,7 @@ export function createToshiClient(options) {
79
83
  create: (req, opts) => workloadService.createService(req, opts),
80
84
  get: (req, opts) => workloadService.getService(req, opts),
81
85
  list: (req, opts) => workloadService.listServices(req, opts),
86
+ all: (req, opts) => paginate(req, workloadService.listServices, (resp) => resp.services, opts),
82
87
  update: (req, opts) => workloadService.updateService(req, opts),
83
88
  delete: (req, opts) => workloadService.deleteService(req, opts),
84
89
  wait: (req, opts) => waitService(workloadService, create(WaitServiceRequestSchema, req), opts),
@@ -90,9 +95,11 @@ export function createToshiClient(options) {
90
95
  wake: (req, opts) => workloadService.wakeService(req, opts),
91
96
  hold: (req, opts) => workloadService.holdService(req, opts),
92
97
  listRuns: (req, opts) => workloadService.listServiceRuns(req, opts),
98
+ allRuns: (req, opts) => paginate(req, workloadService.listServiceRuns, (resp) => resp.runs, opts),
93
99
  },
94
100
  waitOperation: (req, opts) => waitOperation(workloadService, create(WaitOperationRequestSchema, req), opts),
95
101
  listEvents: (req, opts) => workloadService.listEvents(req, opts),
102
+ allEvents: (req, opts) => paginate(req, workloadService.listEvents, (resp) => resp.events, opts),
96
103
  watchEvents: (req, opts) => openEventWatch(workloadService, retry, create(WatchEventsRequestSchema, req), opts),
97
104
  getOperation: (req, opts) => workloadService.getOperation(req, opts),
98
105
  },
package/dist/execute.js CHANGED
@@ -3,6 +3,7 @@ import { Code, ConnectError } from "@connectrpc/connect";
3
3
  import { createWritableIterable } from "@connectrpc/connect/protocol";
4
4
  import { ExitError, StreamLostError } from "./errors.js";
5
5
  import { isStreamLoss } from "./retry.js";
6
+ import { startDeadline, timeoutError } from "./timeout.js";
6
7
  import { ExecuteRequestSchema, ExecuteStartSchema, } from "./gen/toshi/workspace/v1/workspace_pb.js";
7
8
  // The largest standard-input frame Execute sends (execute.go:14-16).
8
9
  const STDIN_CHUNK = 64 << 10;
@@ -35,13 +36,24 @@ export async function executeCommand(service, start, opts) {
35
36
  }
36
37
  owner.process.cancel();
37
38
  };
39
+ // A timeout bounds the handshake only; the process outlives it.
40
+ const deadline = startDeadline(opts?.timeoutMs, opts?.signal);
41
+ const onDeadline = () => {
42
+ if (owner.process === undefined) {
43
+ abort.abort(deadline?.signal.reason);
44
+ }
45
+ };
38
46
  // A signal that is already aborted starts nothing.
39
47
  if (signal?.aborted === true) {
48
+ deadline?.clear();
40
49
  throw cancelledError();
41
50
  }
42
51
  signal?.addEventListener("abort", onAbort, { once: true });
52
+ deadline?.signal.addEventListener("abort", onDeadline, { once: true });
43
53
  const finishHandshake = () => {
44
54
  signal?.removeEventListener("abort", onAbort);
55
+ deadline?.signal.removeEventListener("abort", onDeadline);
56
+ deadline?.clear();
45
57
  };
46
58
  const responses = service.execute(input, { signal: abort.signal });
47
59
  const responseIterator = responses[Symbol.asyncIterator]();
@@ -73,6 +85,9 @@ export async function executeCommand(service, start, opts) {
73
85
  if (signal !== undefined && signal.aborted) {
74
86
  throw cancelledError();
75
87
  }
88
+ if (deadline?.timedOut === true) {
89
+ throw timeoutError(opts?.timeoutMs);
90
+ }
76
91
  throw beforeStarted(err, readRequestKey(startMessage));
77
92
  }
78
93
  const message = first.done === true ? undefined : first.value.message;
package/dist/index.d.ts CHANGED
@@ -16,7 +16,10 @@ export * from "./gen/toshi/workspace/v1/storage_providers_pb.js";
16
16
  export * from "./gen/toshi/workspace/v1/tokens_pb.js";
17
17
  export * from "./gen/toshi/workspace/v1/workspace_service_pb.js";
18
18
  export { createToshiClient } from "./client.js";
19
+ export { paginate } from "./paginate.js";
19
20
  export { ExitError, JobExitError, StreamLostError } from "./errors.js";
20
21
  export { defaultRetryPolicy } from "./retry.js";
22
+ export { VERSION } from "./version.js";
23
+ export type { PageRequest, PageResponse } from "./paginate.js";
21
24
  export type { RetryPolicy } from "./retry.js";
22
25
  export type { CallOptions, EventWatch, JobRun, JobWatch, JobsClient, OutputChunk, OutputWatch, Process, SandboxWatch, SandboxesClient, ServiceWatch, ServicesClient, ToshiClient, ToshiOptions, WorkloadClient, } from "./client.js";
package/dist/index.js CHANGED
@@ -17,5 +17,7 @@ export * from "./gen/toshi/workspace/v1/storage_providers_pb.js";
17
17
  export * from "./gen/toshi/workspace/v1/tokens_pb.js";
18
18
  export * from "./gen/toshi/workspace/v1/workspace_service_pb.js";
19
19
  export { createToshiClient } from "./client.js";
20
+ export { paginate } from "./paginate.js";
20
21
  export { ExitError, JobExitError, StreamLostError } from "./errors.js";
21
22
  export { defaultRetryPolicy } from "./retry.js";
23
+ export { VERSION } from "./version.js";
@@ -1,7 +1,16 @@
1
1
  import type { Interceptor } from "@connectrpc/connect";
2
2
  import type { RetrySetting } from "./retry.js";
3
- /** Sends `Authorization: Bearer <token>` on every call and stream; never Toshi-Tenant (intercept.go:13-36). */
4
- export declare function authInterceptor(token: string): Interceptor;
3
+ /** Gives one request attempt its token; the signal is the call's. */
4
+ export type TokenSource = (signal: AbortSignal | undefined) => string | Promise<string>;
5
+ /**
6
+ * Sends `Authorization: Bearer <token>` on every call and stream, on a copy of
7
+ * the request: it is the innermost interceptor, so none outside it sees the
8
+ * header (intercept.go:13-31). Never Toshi-Tenant.
9
+ *
10
+ * With a source, the token is asked for on every attempt the innermost
11
+ * interceptor runs for, so a retry asks again (client.go:154-169).
12
+ */
13
+ export declare function authInterceptor(token: string, source?: TokenSource): Interceptor;
5
14
  /** Fills an empty project and environment from the options; caller values win (intercept.go:38-129). */
6
15
  export declare function scopeInterceptor(project: string, environment: string): Interceptor;
7
16
  /** Puts a request key on keyed procedures and on Execute's start (retry.go:110-116, 202-231). */
@@ -1,15 +1,51 @@
1
1
  import { ScalarType } from "@bufbuild/protobuf";
2
+ import { Code, ConnectError } from "@connectrpc/connect";
2
3
  import { backoffDelay, isTransportLoss, newRequestKey, procedurePath, retryClassFor, sleep, } from "./retry.js";
3
- /** Sends `Authorization: Bearer <token>` on every call and stream; never Toshi-Tenant (intercept.go:13-36). */
4
- export function authInterceptor(token) {
4
+ /**
5
+ * Sends `Authorization: Bearer <token>` on every call and stream, on a copy of
6
+ * the request: it is the innermost interceptor, so none outside it sees the
7
+ * header (intercept.go:13-31). Never Toshi-Tenant.
8
+ *
9
+ * With a source, the token is asked for on every attempt the innermost
10
+ * interceptor runs for, so a retry asks again (client.go:154-169).
11
+ */
12
+ export function authInterceptor(token, source) {
13
+ if (source !== undefined) {
14
+ return (next) => async (req) => {
15
+ const value = await sourceToken(source, req.signal);
16
+ const header = new Headers(req.header);
17
+ header.set("Authorization", `Bearer ${value}`);
18
+ return next({ ...req, header });
19
+ };
20
+ }
5
21
  if (token === "") {
6
22
  return (next) => next;
7
23
  }
8
24
  return (next) => async (req) => {
9
- req.header.set("Authorization", `Bearer ${token}`);
10
- return next(req);
25
+ const header = new Headers(req.header);
26
+ header.set("Authorization", `Bearer ${token}`);
27
+ return next({ ...req, header });
11
28
  };
12
29
  }
30
+ // Asks the source for this attempt's token, trimmed; a source that throws or
31
+ // answers empty ends the call as UNAUTHENTICATED with the source's error as the
32
+ // cause, which is not a transport loss and so is not retried (client.go:186-201).
33
+ async function sourceToken(source, signal) {
34
+ let token;
35
+ try {
36
+ token = await source(signal);
37
+ }
38
+ catch (err) {
39
+ throw new ConnectError(`toshi: token source: ${message(err)}`, Code.Unauthenticated, undefined, undefined, err);
40
+ }
41
+ if (token.trim() === "") {
42
+ throw new ConnectError("toshi: token source: empty token", Code.Unauthenticated);
43
+ }
44
+ return token.trim();
45
+ }
46
+ function message(err) {
47
+ return err instanceof Error ? err.message : String(err);
48
+ }
13
49
  /** Fills an empty project and environment from the options; caller values win (intercept.go:38-129). */
14
50
  export function scopeInterceptor(project, environment) {
15
51
  if (project === "" && environment === "") {
@@ -0,0 +1,35 @@
1
+ import type { Interceptor, Transport } from "@connectrpc/connect";
2
+ import type { ToshiClient, ToshiOptions } from "../client.js";
3
+ /** Options for the Node entry: the root options, a built transport, and env defaults. */
4
+ export interface NodeToshiOptions extends Omit<ToshiOptions, "transport"> {
5
+ /** A transport of your own; without one, connect-node over HTTP/2 to `server` is built. */
6
+ transport?: (interceptors: Interceptor[]) => Transport;
7
+ /** The control plane's base URL; defaults to TOSHI_SERVER. */
8
+ server?: string;
9
+ /**
10
+ * A file holding the token, trimmed; an empty file is an error. It is read
11
+ * again before a request when its modification time or size changed.
12
+ */
13
+ tokenFile?: string;
14
+ /** A PEM bundle trusted besides Node's built-in roots; defaults to TOSHI_CA_FILE. */
15
+ caFile?: string;
16
+ /** Put in front of the User-Agent, for example "myapp/1.2"; empty adds nothing. */
17
+ userAgent?: string;
18
+ /** Run before the client's scope, key, retry and auth interceptors, in this order. */
19
+ interceptors?: Interceptor[];
20
+ /** Bounds each unary call except the Wait calls, which their request's timeout bounds; streams ignore it. */
21
+ defaultTimeoutMs?: number;
22
+ }
23
+ /**
24
+ * Creates a Node client. Without a transport of its own it builds one with
25
+ * connect-node over HTTP/2, takes the server from `server` or TOSHI_SERVER and
26
+ * the token from `tokenSource`, `token`, `tokenFile`, TOSHI_TOKEN or
27
+ * TOSHI_TOKEN_FILE.
28
+ *
29
+ * The User-Agent is "toshi-ts/<version> (node/<node version>)", with
30
+ * `userAgent` in front when it is set. Connect exports no version constant, so
31
+ * its name is not part of the string.
32
+ */
33
+ export declare function createToshiClient(options?: NodeToshiOptions): ToshiClient;
34
+ /** The User-Agent every Node request carries, with product in front when set. */
35
+ export declare function userAgent(product: string): string;