@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.
- package/README.md +189 -30
- package/dist/client.d.ts +30 -4
- package/dist/client.js +19 -6
- package/dist/errors.d.ts +47 -0
- package/dist/errors.js +136 -0
- package/dist/execute.js +15 -0
- package/dist/gen/toshi/permission_pb.d.ts +39 -0
- package/dist/gen/toshi/permission_pb.js +42 -0
- package/dist/gen/toshi/workspace/v1/artifacts_pb.d.ts +1 -1
- package/dist/gen/toshi/workspace/v1/artifacts_pb.js +3 -2
- package/dist/gen/toshi/workspace/v1/audit_events_pb.js +2 -1
- package/dist/gen/toshi/workspace/v1/channels_pb.js +2 -1
- package/dist/gen/toshi/workspace/v1/discovery_pb.d.ts +676 -0
- package/dist/gen/toshi/workspace/v1/discovery_pb.js +182 -0
- package/dist/gen/toshi/workspace/v1/display_pb.js +2 -1
- package/dist/gen/toshi/workspace/v1/fillers_pb.js +2 -1
- package/dist/gen/toshi/workspace/v1/hostjobs_pb.js +2 -1
- package/dist/gen/toshi/workspace/v1/hosts_pb.js +2 -1
- package/dist/gen/toshi/workspace/v1/products_pb.d.ts +703 -0
- package/dist/gen/toshi/workspace/v1/products_pb.js +106 -0
- package/dist/gen/toshi/workspace/v1/projects_pb.js +2 -1
- package/dist/gen/toshi/workspace/v1/recordings_pb.js +2 -1
- package/dist/gen/toshi/workspace/v1/secrets_pb.js +2 -1
- package/dist/gen/toshi/workspace/v1/storage_providers_pb.js +2 -1
- package/dist/gen/toshi/workspace/v1/tokens_pb.js +2 -1
- package/dist/gen/toshi/workspace/v1/volumes_pb.js +2 -1
- package/dist/gen/toshi/workspace/v1/webhook_pb.js +2 -1
- package/dist/gen/toshi/workspace/v1/workloads_pb.js +2 -1
- package/dist/gen/toshi/workspace/v1/workspace_pb.d.ts +2 -2
- package/dist/gen/toshi/workspace/v1/workspace_pb.js +1 -1
- package/dist/gen/toshi/workspace/v1/workspace_service_pb.js +2 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +5 -1
- package/dist/interceptors.d.ts +17 -2
- package/dist/interceptors.js +69 -4
- package/dist/node/client.d.ts +35 -0
- package/dist/node/client.js +178 -0
- package/dist/node/index.d.ts +3 -0
- package/dist/node/index.js +4 -0
- package/dist/output.js +6 -1
- package/dist/paginate.d.ts +36 -0
- package/dist/paginate.js +37 -0
- package/dist/retry.js +4 -0
- package/dist/run.js +3 -2
- package/dist/stream.d.ts +8 -1
- package/dist/stream.js +24 -2
- package/dist/test/fake.d.ts +88 -0
- package/dist/test/fake.js +845 -0
- package/dist/test/index.d.ts +58 -0
- package/dist/test/index.js +57 -0
- package/dist/test/procedures.d.ts +18 -0
- package/dist/test/procedures.js +219 -0
- package/dist/timeout.d.ts +29 -0
- package/dist/timeout.js +67 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/dist/wait.js +15 -12
- package/dist/watch.js +15 -2
- 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 {
|
|
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";
|
package/dist/interceptors.d.ts
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
import type { Interceptor } from "@connectrpc/connect";
|
|
2
2
|
import type { RetrySetting } from "./retry.js";
|
|
3
|
-
/**
|
|
4
|
-
export
|
|
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;
|
package/dist/interceptors.js
CHANGED
|
@@ -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
|
-
|
|
4
|
-
|
|
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
|
|
10
|
-
|
|
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
|
+
}
|
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>;
|
package/dist/paginate.js
ADDED
|
@@ -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,
|
|
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
|
}
|