@toshicloud/sdk 0.3.1 → 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,49 +4,43 @@ 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.
18
+ In Node, `@toshicloud/sdk/node` builds the transport and fills the server
19
+ and the token from the environment:
19
20
 
20
- In Node, with `@connectrpc/connect-node`:
21
+ <!-- examples/quick-start.ts -->
21
22
 
22
23
  ```ts
23
- import { readFileSync } from "node:fs";
24
- import { createConnectTransport } from "@connectrpc/connect-node";
25
- import { createToshiClient } from "@toshicloud/sdk";
24
+ import { createToshiClient } from "@toshicloud/sdk/node";
26
25
 
27
- const toshi = createToshiClient({
28
- transport: (interceptors) =>
29
- createConnectTransport({
30
- baseUrl: "https://toshi.example.com",
31
- httpVersion: "2",
32
- interceptors,
33
- // Only for a server whose certificate a private CA signed.
34
- nodeOptions: { ca: readFileSync("ca.pem") },
35
- }),
36
- token: process.env.TOSHI_TOKEN,
37
- project: "default",
38
- });
26
+ const toshi = createToshiClient({ project: "default" });
39
27
 
40
28
  const { projects } = await toshi.project.listProjects({});
29
+ console.log(projects.length);
41
30
  ```
42
31
 
43
32
  In a browser, with `@connectrpc/connect-web`:
44
33
 
34
+ <!-- examples/browser.ts -->
35
+
45
36
  ```ts
46
37
  import { createConnectTransport } from "@connectrpc/connect-web";
47
38
  import { createToshiClient } from "@toshicloud/sdk";
48
39
 
49
- const toshi = createToshiClient({
40
+ // The token the browser app already holds.
41
+ declare const token: string;
42
+
43
+ export const toshi = createToshiClient({
50
44
  transport: (interceptors) =>
51
45
  createConnectTransport({ baseUrl: "https://toshi.example.com", interceptors }),
52
46
  token,
@@ -54,14 +48,21 @@ const toshi = createToshiClient({
54
48
  });
55
49
  ```
56
50
 
57
- To add interceptors of your own, put them before the client's:
58
- `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.
59
56
 
60
- The package reads no environment variable and no file. The token, the
61
- 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.
62
60
 
63
61
  ### Options
64
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.
65
66
  - `token`: sent as `Authorization: Bearer <token>` on every call.
66
67
  - `project`, `environment`: fill the request's `project` and
67
68
  `environment` fields when they are empty. A value in the request wins.
@@ -69,6 +70,65 @@ server and any CA come from the caller.
69
70
  - `retry`: `{ maxAttempts, initialBackoffMs, maxBackoffMs }`, by default
70
71
  5, 200 ms and 10 s, with full jitter. `retry: false` turns off retries
71
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.
72
132
 
73
133
  ### The client's shape
74
134
 
@@ -86,11 +146,51 @@ span the three types sit on `workload` itself: `listEvents`, `watchEvents`,
86
146
  `getOperation` and `waitOperation`. `run` sits at the top of the client, as
87
147
  `toshi run` sits at the top of the CLI.
88
148
 
149
+ <!-- examples/client-shape.ts -->
150
+
89
151
  ```ts
90
152
  const { jobs } = await toshi.workload.jobs.list({ pageSize: 10 });
91
- const job = await toshi.workload.jobs.get({ id: jobs[0].id });
153
+ const { job } = await toshi.workload.jobs.get({ id: jobs[0].id });
154
+ console.log(job?.id);
155
+ ```
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
+ }
92
187
  ```
93
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
+
94
194
  ### Retries
95
195
 
96
196
  The client retries a read, and a write that carries a request key, when
@@ -130,7 +230,10 @@ from a server error.
130
230
  `WatchEventsResponse` (`event`), and the output watch yields
131
231
  `WatchOutputResponse` (`stdout`, `stderr` or `end`).
132
232
 
233
+ <!-- examples/watch-events.ts -->
234
+
133
235
  ```ts
236
+ const id = "workload-id";
134
237
  const watch = await toshi.workload.watchEvents({ workloadId: id });
135
238
  for await (const { event } of watch) {
136
239
  console.log(event?.name);
@@ -151,7 +254,10 @@ client holds at most one buffer of input. `closeStdin` ends standard input
151
254
  after a pending write, and a later `write` is refused. `wait` settles when
152
255
  the command ends.
153
256
 
257
+ <!-- examples/execute.ts -->
258
+
154
259
  ```ts
260
+ const id = "sandbox-id";
155
261
  const proc = await toshi.workload.sandboxes.execute({
156
262
  sandboxId: id,
157
263
  command: { argv: ["cat"] },
@@ -184,6 +290,8 @@ job and its output, which is what `toshi run` does: `CreateJob`, then
184
290
  `WatchOutput`. It yields output chunks like an execution, and `wait()`
185
291
  resolves when the job exits 0 and rejects with `JobExitError` otherwise.
186
292
 
293
+ <!-- examples/run-job.ts -->
294
+
187
295
  ```ts
188
296
  const run = await toshi.run({
189
297
  spec: { command: { argv: ["./test.sh"] } },
@@ -197,6 +305,35 @@ await run.wait(); // rejects with JobExitError on a non-zero exit
197
305
  Stopping the loop stops only the output stream: the job keeps running,
198
306
  and `workload.jobs.cancel` ends it.
199
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
+
200
337
  ## Generated code
201
338
 
202
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;