@toshicloud/sdk 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +189 -30
  2. package/dist/client.d.ts +30 -4
  3. package/dist/client.js +19 -6
  4. package/dist/errors.d.ts +47 -0
  5. package/dist/errors.js +136 -0
  6. package/dist/execute.js +15 -0
  7. package/dist/gen/toshi/permission_pb.d.ts +39 -0
  8. package/dist/gen/toshi/permission_pb.js +42 -0
  9. package/dist/gen/toshi/workspace/v1/artifacts_pb.d.ts +1 -1
  10. package/dist/gen/toshi/workspace/v1/artifacts_pb.js +3 -2
  11. package/dist/gen/toshi/workspace/v1/audit_events_pb.js +2 -1
  12. package/dist/gen/toshi/workspace/v1/channels_pb.js +2 -1
  13. package/dist/gen/toshi/workspace/v1/discovery_pb.d.ts +676 -0
  14. package/dist/gen/toshi/workspace/v1/discovery_pb.js +182 -0
  15. package/dist/gen/toshi/workspace/v1/display_pb.js +2 -1
  16. package/dist/gen/toshi/workspace/v1/fillers_pb.js +2 -1
  17. package/dist/gen/toshi/workspace/v1/hostjobs_pb.js +2 -1
  18. package/dist/gen/toshi/workspace/v1/hosts_pb.js +2 -1
  19. package/dist/gen/toshi/workspace/v1/products_pb.d.ts +703 -0
  20. package/dist/gen/toshi/workspace/v1/products_pb.js +106 -0
  21. package/dist/gen/toshi/workspace/v1/projects_pb.js +2 -1
  22. package/dist/gen/toshi/workspace/v1/recordings_pb.js +2 -1
  23. package/dist/gen/toshi/workspace/v1/secrets_pb.js +2 -1
  24. package/dist/gen/toshi/workspace/v1/storage_providers_pb.js +2 -1
  25. package/dist/gen/toshi/workspace/v1/tokens_pb.js +2 -1
  26. package/dist/gen/toshi/workspace/v1/volumes_pb.js +2 -1
  27. package/dist/gen/toshi/workspace/v1/webhook_pb.js +2 -1
  28. package/dist/gen/toshi/workspace/v1/workloads_pb.js +2 -1
  29. package/dist/gen/toshi/workspace/v1/workspace_pb.d.ts +2 -2
  30. package/dist/gen/toshi/workspace/v1/workspace_pb.js +1 -1
  31. package/dist/gen/toshi/workspace/v1/workspace_service_pb.js +2 -1
  32. package/dist/index.d.ts +6 -1
  33. package/dist/index.js +5 -1
  34. package/dist/interceptors.d.ts +17 -2
  35. package/dist/interceptors.js +69 -4
  36. package/dist/node/client.d.ts +35 -0
  37. package/dist/node/client.js +178 -0
  38. package/dist/node/index.d.ts +3 -0
  39. package/dist/node/index.js +4 -0
  40. package/dist/output.js +6 -1
  41. package/dist/paginate.d.ts +36 -0
  42. package/dist/paginate.js +37 -0
  43. package/dist/retry.js +4 -0
  44. package/dist/run.js +3 -2
  45. package/dist/stream.d.ts +8 -1
  46. package/dist/stream.js +24 -2
  47. package/dist/test/fake.d.ts +88 -0
  48. package/dist/test/fake.js +845 -0
  49. package/dist/test/index.d.ts +58 -0
  50. package/dist/test/index.js +57 -0
  51. package/dist/test/procedures.d.ts +18 -0
  52. package/dist/test/procedures.js +219 -0
  53. package/dist/timeout.d.ts +29 -0
  54. package/dist/timeout.js +67 -0
  55. package/dist/version.d.ts +2 -0
  56. package/dist/version.js +2 -0
  57. package/dist/wait.js +15 -12
  58. package/dist/watch.js +15 -2
  59. package/package.json +19 -3
package/dist/index.js CHANGED
@@ -15,7 +15,11 @@ export * from "./gen/toshi/workspace/v1/hostjobs_pb.js";
15
15
  export * from "./gen/toshi/workspace/v1/hosts_pb.js";
16
16
  export * from "./gen/toshi/workspace/v1/storage_providers_pb.js";
17
17
  export * from "./gen/toshi/workspace/v1/tokens_pb.js";
18
+ export * from "./gen/toshi/workspace/v1/products_pb.js";
19
+ export * from "./gen/toshi/workspace/v1/discovery_pb.js";
18
20
  export * from "./gen/toshi/workspace/v1/workspace_service_pb.js";
19
21
  export { createToshiClient } from "./client.js";
20
- export { ExitError, JobExitError, StreamLostError } from "./errors.js";
22
+ export { paginate } from "./paginate.js";
23
+ export { ExitError, JobExitError, ProductError, ProductReason, StreamLostError, isProductError } from "./errors.js";
21
24
  export { defaultRetryPolicy } from "./retry.js";
25
+ export { VERSION } from "./version.js";
@@ -1,10 +1,25 @@
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). */
8
17
  export declare function keyInterceptor(): Interceptor;
9
18
  /** Retries a unary read or keyed write after a transport loss, with full jitter (retry.go:98-186). */
10
19
  export declare function retryInterceptor(retry: RetrySetting): Interceptor;
20
+ /**
21
+ * Throws a ProductError in place of a product's refusal, on a unary call and
22
+ * on a stream's messages. It is the outermost interceptor, so the retry and
23
+ * the helpers inside see the ConnectError they always did.
24
+ */
25
+ export declare function productInterceptor(): Interceptor;
@@ -1,15 +1,52 @@
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
+ import { asProductError } from "./errors.js";
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 function authInterceptor(token, source) {
14
+ if (source !== undefined) {
15
+ return (next) => async (req) => {
16
+ const value = await sourceToken(source, req.signal);
17
+ const header = new Headers(req.header);
18
+ header.set("Authorization", `Bearer ${value}`);
19
+ return next({ ...req, header });
20
+ };
21
+ }
5
22
  if (token === "") {
6
23
  return (next) => next;
7
24
  }
8
25
  return (next) => async (req) => {
9
- req.header.set("Authorization", `Bearer ${token}`);
10
- return next(req);
26
+ const header = new Headers(req.header);
27
+ header.set("Authorization", `Bearer ${token}`);
28
+ return next({ ...req, header });
11
29
  };
12
30
  }
31
+ // Asks the source for this attempt's token, trimmed; a source that throws or
32
+ // answers empty ends the call as UNAUTHENTICATED with the source's error as the
33
+ // cause, which is not a transport loss and so is not retried (client.go:186-201).
34
+ async function sourceToken(source, signal) {
35
+ let token;
36
+ try {
37
+ token = await source(signal);
38
+ }
39
+ catch (err) {
40
+ throw new ConnectError(`toshi: token source: ${message(err)}`, Code.Unauthenticated, undefined, undefined, err);
41
+ }
42
+ if (token.trim() === "") {
43
+ throw new ConnectError("toshi: token source: empty token", Code.Unauthenticated);
44
+ }
45
+ return token.trim();
46
+ }
47
+ function message(err) {
48
+ return err instanceof Error ? err.message : String(err);
49
+ }
13
50
  /** Fills an empty project and environment from the options; caller values win (intercept.go:38-129). */
14
51
  export function scopeInterceptor(project, environment) {
15
52
  if (project === "" && environment === "") {
@@ -157,3 +194,31 @@ function isShared(msg, desc) {
157
194
  field.scalar === ScalarType.BOOL &&
158
195
  msg[field.localName] === true);
159
196
  }
197
+ /**
198
+ * Throws a ProductError in place of a product's refusal, on a unary call and
199
+ * on a stream's messages. It is the outermost interceptor, so the retry and
200
+ * the helpers inside see the ConnectError they always did.
201
+ */
202
+ export function productInterceptor() {
203
+ return (next) => async (req) => {
204
+ let res;
205
+ try {
206
+ res = await next(req);
207
+ }
208
+ catch (err) {
209
+ throw asProductError(err);
210
+ }
211
+ if (!res.stream) {
212
+ return res;
213
+ }
214
+ return { ...res, message: productStream(res.message) };
215
+ };
216
+ }
217
+ async function* productStream(messages) {
218
+ try {
219
+ yield* messages;
220
+ }
221
+ catch (err) {
222
+ throw asProductError(err);
223
+ }
224
+ }
@@ -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;
@@ -0,0 +1,178 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { versions } from "node:process";
3
+ import { rootCertificates } from "node:tls";
4
+ import { createConnectTransport } from "@connectrpc/connect-node";
5
+ import { createToshiClient as createRootClient } from "../client.js";
6
+ import { VERSION } from "../version.js";
7
+ /**
8
+ * Creates a Node client. Without a transport of its own it builds one with
9
+ * connect-node over HTTP/2, takes the server from `server` or TOSHI_SERVER and
10
+ * the token from `tokenSource`, `token`, `tokenFile`, TOSHI_TOKEN or
11
+ * TOSHI_TOKEN_FILE.
12
+ *
13
+ * The User-Agent is "toshi-ts/<version> (node/<node version>)", with
14
+ * `userAgent` in front when it is set. Connect exports no version constant, so
15
+ * its name is not part of the string.
16
+ */
17
+ export function createToshiClient(options = {}) {
18
+ const custom = options.transport;
19
+ const resolved = resolveToken(options, custom !== undefined);
20
+ const agent = userAgent(options.userAgent ?? "");
21
+ const outer = (interceptors) => [
22
+ ...(options.interceptors ?? []),
23
+ userAgentInterceptor(agent),
24
+ ...interceptors,
25
+ ];
26
+ let transport = custom;
27
+ if (transport === undefined) {
28
+ const server = resolveServer(options.server);
29
+ const caFile = resolveCAFile(options.caFile);
30
+ const ca = caFile === undefined ? undefined : caBundle(caFile);
31
+ transport = (interceptors) => createConnectTransport({
32
+ baseUrl: server,
33
+ httpVersion: "2",
34
+ interceptors: outer(interceptors),
35
+ ...(ca === undefined ? {} : { nodeOptions: { ca } }),
36
+ });
37
+ }
38
+ else {
39
+ const transportOfCaller = transport;
40
+ transport = (interceptors) => transportOfCaller(outer(interceptors));
41
+ }
42
+ return createRootClient({
43
+ ...options,
44
+ transport,
45
+ token: resolved.token,
46
+ tokenSource: resolved.tokenSource,
47
+ });
48
+ }
49
+ /** The User-Agent every Node request carries, with product in front when set. */
50
+ export function userAgent(product) {
51
+ const base = `toshi-ts/${VERSION} (node/${versions.node})`;
52
+ return product === "" ? base : `${product} ${base}`;
53
+ }
54
+ function userAgentInterceptor(agent) {
55
+ return (next) => async (req) => {
56
+ const header = new Headers(req.header);
57
+ header.set("User-Agent", agent);
58
+ return next({ ...req, header });
59
+ };
60
+ }
61
+ function resolveServer(given) {
62
+ const server = given !== undefined && given !== "" ? given : env("TOSHI_SERVER");
63
+ if (server === undefined) {
64
+ throw new Error("toshi: no server: use the server option or set TOSHI_SERVER");
65
+ }
66
+ return server;
67
+ }
68
+ // resolveToken picks the token by the documented precedence: the source, the
69
+ // option, the option's file, TOSHI_TOKEN, then TOSHI_TOKEN_FILE
70
+ // (client.go:154-169). A token file is read here, so a missing or empty one
71
+ // fails createToshiClient; the source reads it again when it changes. With a
72
+ // transport of the caller's and no token anywhere, it returns nothing instead
73
+ // of refusing.
74
+ function resolveToken(options, optional) {
75
+ if (options.tokenSource !== undefined) {
76
+ return { tokenSource: options.tokenSource };
77
+ }
78
+ const token = options.token?.trim() ?? "";
79
+ if (token !== "") {
80
+ return { token };
81
+ }
82
+ if (options.tokenFile !== undefined && options.tokenFile !== "") {
83
+ return { tokenSource: fileTokenSource(options.tokenFile) };
84
+ }
85
+ const fromEnv = env("TOSHI_TOKEN")?.trim() ?? "";
86
+ if (fromEnv !== "") {
87
+ return { token: fromEnv };
88
+ }
89
+ const envFile = env("TOSHI_TOKEN_FILE");
90
+ if (envFile !== undefined) {
91
+ return { tokenSource: fileTokenSource(envFile) };
92
+ }
93
+ if (optional) {
94
+ return {};
95
+ }
96
+ throw new Error("toshi: no token: use the tokenSource, token or tokenFile option, or set TOSHI_TOKEN or TOSHI_TOKEN_FILE");
97
+ }
98
+ // fileTokenSource returns a source over a token file whose first read happens
99
+ // now, so a missing or empty file still fails createToshiClient.
100
+ function fileTokenSource(path) {
101
+ const file = new TokenFile(path);
102
+ return () => file.read();
103
+ }
104
+ // TokenFile is a token file read again when its modification time or size
105
+ // changed since the last read (client.go:203-249).
106
+ class TokenFile {
107
+ #path;
108
+ #token = "";
109
+ #modTimeNs = 0n;
110
+ #size = -1n;
111
+ constructor(path) {
112
+ this.#path = path;
113
+ this.read();
114
+ }
115
+ // read returns the file's token, reading the file again when it changed. It
116
+ // stats before reading, so a change made in between is read next time. After
117
+ // a failure the next call reads the file again.
118
+ read() {
119
+ let stats;
120
+ try {
121
+ stats = statSync(this.#path, { bigint: true });
122
+ }
123
+ catch (err) {
124
+ this.#size = -1n;
125
+ throw new Error(`toshi: read token file ${this.#path}: ${message(err)}`, { cause: err });
126
+ }
127
+ if (stats.mtimeNs === this.#modTimeNs && stats.size === this.#size) {
128
+ return this.#token;
129
+ }
130
+ try {
131
+ this.#token = readTokenFile(this.#path);
132
+ }
133
+ catch (err) {
134
+ this.#size = -1n;
135
+ throw err;
136
+ }
137
+ this.#modTimeNs = stats.mtimeNs;
138
+ this.#size = stats.size;
139
+ return this.#token;
140
+ }
141
+ }
142
+ function resolveCAFile(given) {
143
+ if (given !== undefined && given !== "") {
144
+ return given;
145
+ }
146
+ return env("TOSHI_CA_FILE");
147
+ }
148
+ function readTokenFile(path) {
149
+ let contents;
150
+ try {
151
+ contents = readFileSync(path, "utf8");
152
+ }
153
+ catch (err) {
154
+ throw new Error(`toshi: read token file ${path}: ${message(err)}`, { cause: err });
155
+ }
156
+ const token = contents.trim();
157
+ if (token === "") {
158
+ throw new Error(`toshi: token file ${path} is empty`);
159
+ }
160
+ return token;
161
+ }
162
+ // The bundle is trusted besides the built-in roots, as the Go client trusts the
163
+ // system pool besides the file (client.go:206-237).
164
+ function caBundle(path) {
165
+ try {
166
+ return [...rootCertificates, readFileSync(path, "utf8")];
167
+ }
168
+ catch (err) {
169
+ throw new Error(`toshi: read CA file ${path}: ${message(err)}`, { cause: err });
170
+ }
171
+ }
172
+ function env(name) {
173
+ const value = process.env[name];
174
+ return value === undefined || value === "" ? undefined : value;
175
+ }
176
+ function message(err) {
177
+ return err instanceof Error ? err.message : String(err);
178
+ }
@@ -0,0 +1,3 @@
1
+ export * from "../index.js";
2
+ export { createToshiClient } from "./client.js";
3
+ export type { NodeToshiOptions } from "./client.js";
@@ -0,0 +1,4 @@
1
+ // The Node entry: everything the root entry exports, with a createToshiClient
2
+ // that also builds a connect-node transport and reads the TOSHI_* variables.
3
+ export * from "../index.js";
4
+ export { createToshiClient } from "./client.js";
package/dist/output.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { clone } from "@bufbuild/protobuf";
2
2
  import { WatchOutputRequestSchema } from "./gen/toshi/workspace/v1/workspace_pb.js";
3
3
  import { READER_TAKEN, WatchState, createReader, reconnect, streamEnded } from "./stream.js";
4
+ import { timeoutError } from "./timeout.js";
4
5
  /** Starts an output watch at the request's offsets, counting the bytes it hands out (output.go:14-52). */
5
6
  export function openOutputWatch(service, retry, req, opts) {
6
- const state = new WatchState("", opts?.signal);
7
+ const state = new WatchState("", opts?.signal, opts?.timeoutMs);
7
8
  const request = clone(WatchOutputRequestSchema, req);
8
9
  let stdout = req.stdoutOffset;
9
10
  let stderr = req.stderrOffset;
@@ -20,6 +21,7 @@ export function openOutputWatch(service, retry, req, opts) {
20
21
  request.stderrOffset = stderr;
21
22
  const stream = service.watchOutput(request, { signal: state.signal });
22
23
  for await (const msg of stream) {
24
+ state.markOpen();
23
25
  attempt = 0;
24
26
  if (msg.message.case === "end") {
25
27
  // output.go:157-160: release the RPC before the caller sees the
@@ -45,6 +47,9 @@ export function openOutputWatch(service, retry, req, opts) {
45
47
  if (state.closed) {
46
48
  return;
47
49
  }
50
+ if (state.timedOut) {
51
+ throw timeoutError(state.timeoutMs);
52
+ }
48
53
  cause = err;
49
54
  }
50
55
  attempt = await reconnect(retry, attempt, cause, state);
@@ -0,0 +1,36 @@
1
+ import type { CallOptions } from "./client.js";
2
+ /** A List request: it may name the page to read first. */
3
+ export interface PageRequest {
4
+ /** From an earlier response's nextPageToken; empty for the first page. */
5
+ pageToken?: string | undefined;
6
+ }
7
+ /** A List response: it names the page after it. */
8
+ export interface PageResponse {
9
+ /** Empty on the last page. */
10
+ readonly nextPageToken: string;
11
+ }
12
+ /**
13
+ * Yields every item of a page-at-a-time list call, one page after another.
14
+ *
15
+ * `call` is a service client's list method, and `items` picks a response's
16
+ * items:
17
+ *
18
+ * ```ts
19
+ * for await (const project of paginate(
20
+ * {},
21
+ * (req, opts) => toshi.project.listProjects(req, opts),
22
+ * (resp) => resp.projects,
23
+ * )) {
24
+ * console.log(project.name);
25
+ * }
26
+ * ```
27
+ *
28
+ * `paginate` sends a copy of `req`, so the caller's request keeps its fields
29
+ * and a `pageToken` it set resumes from that page. It reads each response's
30
+ * `nextPageToken` and sends it as the next request's `pageToken`. The
31
+ * iteration ends after a page whose `nextPageToken` is empty, when the loop
32
+ * breaks, or after an error, which the `for await` throws once and then ends.
33
+ * `opts` goes to every page, so `timeoutMs` bounds each page and not the
34
+ * whole iteration.
35
+ */
36
+ export declare function paginate<Req extends PageRequest, Resp extends PageResponse, Item>(req: Req, call: (req: Req, opts?: CallOptions) => Promise<Resp>, items: (resp: Resp) => readonly Item[], opts?: CallOptions): AsyncGenerator<Item, void, undefined>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Yields every item of a page-at-a-time list call, one page after another.
3
+ *
4
+ * `call` is a service client's list method, and `items` picks a response's
5
+ * items:
6
+ *
7
+ * ```ts
8
+ * for await (const project of paginate(
9
+ * {},
10
+ * (req, opts) => toshi.project.listProjects(req, opts),
11
+ * (resp) => resp.projects,
12
+ * )) {
13
+ * console.log(project.name);
14
+ * }
15
+ * ```
16
+ *
17
+ * `paginate` sends a copy of `req`, so the caller's request keeps its fields
18
+ * and a `pageToken` it set resumes from that page. It reads each response's
19
+ * `nextPageToken` and sends it as the next request's `pageToken`. The
20
+ * iteration ends after a page whose `nextPageToken` is empty, when the loop
21
+ * breaks, or after an error, which the `for await` throws once and then ends.
22
+ * `opts` goes to every page, so `timeoutMs` bounds each page and not the
23
+ * whole iteration.
24
+ */
25
+ export async function* paginate(req, call, items, opts) {
26
+ const page = { ...req };
27
+ for (;;) {
28
+ const resp = await call(page, opts);
29
+ for (const item of items(resp)) {
30
+ yield item;
31
+ }
32
+ if (!resp.nextPageToken) {
33
+ return;
34
+ }
35
+ page.pageToken = resp.nextPageToken;
36
+ }
37
+ }
package/dist/retry.js CHANGED
@@ -159,6 +159,10 @@ const procedureRetryClass = {
159
159
  "/toshi.workspace.v1.StorageProviderService/CreateStorageProvider": "none",
160
160
  "/toshi.workspace.v1.StorageProviderService/CheckStorageProvider": "none",
161
161
  "/toshi.workspace.v1.StorageProviderService/DeleteStorageProvider": "none",
162
+ "/toshi.workspace.v1.ProductService/ListProducts": "read",
163
+ "/toshi.workspace.v1.ProductService/UpdateProduct": "none",
164
+ "/toshi.workspace.v1.ProductService/RemoveProduct": "none",
165
+ "/toshi.workspace.v1.DiscoveryService/Discover": "read",
162
166
  };
163
167
  /** The retry class of a generated procedure path; an unknown path is "none". */
164
168
  export function retryClassFor(procedure) {
package/dist/run.js CHANGED
@@ -3,13 +3,14 @@ import { JobExitError } from "./errors.js";
3
3
  import { OutputEndSchema, WatchOutputRequestSchema } from "./gen/toshi/workspace/v1/workspace_pb.js";
4
4
  import { EndReason } from "./gen/toshi/workspace/v1/workspace_pb.js";
5
5
  import { openOutputWatch } from "./output.js";
6
+ import { runWithDeadline } from "./timeout.js";
6
7
  /**
7
8
  * Starts a job and follows its output, as `toshi run` does: CreateJob, then
8
9
  * WatchOutput from the first byte. A job whose create answered its output
9
10
  * (wait_for_end was set) is returned finished.
10
11
  */
11
12
  export async function runJob(service, retry, req, opts) {
12
- const created = await service.createJob(req, opts);
13
+ const created = await runWithDeadline(opts, (callOpts) => service.createJob(req, callOpts));
13
14
  const job = created.job;
14
15
  if (job === undefined) {
15
16
  throw new Error("toshi: CreateJob answered no job");
@@ -21,7 +22,7 @@ export async function runJob(service, retry, req, opts) {
21
22
  project: req.project,
22
23
  environment: req.environment,
23
24
  id: job.id,
24
- }), opts);
25
+ }), opts === undefined ? undefined : { ...opts, timeoutMs: undefined });
25
26
  return new LiveRun(job, watch);
26
27
  }
27
28
  /** A run whose create answered the job's output instead of a live stream. */
package/dist/stream.d.ts CHANGED
@@ -12,10 +12,17 @@ export declare class WatchState {
12
12
  cursor: string;
13
13
  closed: boolean;
14
14
  readonly signal: AbortSignal;
15
+ /** The open timeout, when the caller set one. */
16
+ readonly timeoutMs: number | undefined;
17
+ /** Whether the open timeout fired rather than the caller's signal. */
18
+ timedOut: boolean;
15
19
  private readonly controller;
16
20
  private readonly caller;
21
+ private timer;
17
22
  private readonly onCallerAbort;
18
- constructor(initialCursor: string, caller: AbortSignal | undefined);
23
+ constructor(initialCursor: string, caller: AbortSignal | undefined, timeoutMs?: number);
24
+ /** The first message arrived: the watch is open, and the timeout no longer applies. */
25
+ markOpen(): void;
19
26
  close(): void;
20
27
  }
21
28
  export {};
package/dist/stream.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { abortReason, backoffDelay, isStreamLoss, sleep } from "./retry.js";
2
+ import { timeoutError } from "./timeout.js";
2
3
  /** The one-reader guard every watch shares. */
3
4
  export const READER_TAKEN = "toshi: this watch already has a reader";
4
5
  // watch.go:473-476, output.go:120-127: a stream that ends without its end
@@ -47,22 +48,29 @@ export async function reconnect(retry, attempt, cause, state) {
47
48
  }
48
49
  // Owns the AbortController that cancels the open RPC, a pending next() and any
49
50
  // backoff sleep; close() is the local end, an abort of the caller's signal is an
50
- // error for the caller (watch.go:221-233, output.go:194-206).
51
+ // error for the caller (watch.go:221-233, output.go:194-206). A timeout ends the
52
+ // open only: the first message calls markOpen and clears it.
51
53
  export class WatchState {
52
54
  /** The caller-visible resume point; the output watch passes "" and ignores it. */
53
55
  cursor;
54
56
  closed = false;
55
57
  signal;
58
+ /** The open timeout, when the caller set one. */
59
+ timeoutMs;
60
+ /** Whether the open timeout fired rather than the caller's signal. */
61
+ timedOut = false;
56
62
  controller = new AbortController();
57
63
  caller;
64
+ timer;
58
65
  onCallerAbort = () => {
59
66
  if (this.caller !== undefined) {
60
67
  this.controller.abort(abortReason(this.caller));
61
68
  }
62
69
  };
63
- constructor(initialCursor, caller) {
70
+ constructor(initialCursor, caller, timeoutMs) {
64
71
  this.cursor = initialCursor;
65
72
  this.caller = caller;
73
+ this.timeoutMs = timeoutMs;
66
74
  this.signal = this.controller.signal;
67
75
  if (caller !== undefined) {
68
76
  if (caller.aborted) {
@@ -72,12 +80,26 @@ export class WatchState {
72
80
  caller.addEventListener("abort", this.onCallerAbort, { once: true });
73
81
  }
74
82
  }
83
+ if (timeoutMs !== undefined && !this.controller.signal.aborted) {
84
+ this.timer = setTimeout(() => {
85
+ this.timedOut = true;
86
+ this.controller.abort(timeoutError(timeoutMs));
87
+ }, timeoutMs);
88
+ }
89
+ }
90
+ /** The first message arrived: the watch is open, and the timeout no longer applies. */
91
+ markOpen() {
92
+ if (this.timer !== undefined) {
93
+ clearTimeout(this.timer);
94
+ this.timer = undefined;
95
+ }
75
96
  }
76
97
  close() {
77
98
  if (this.closed) {
78
99
  return;
79
100
  }
80
101
  this.closed = true;
102
+ this.markOpen();
81
103
  this.caller?.removeEventListener("abort", this.onCallerAbort);
82
104
  this.controller.abort();
83
105
  }