@toshicloud/sdk 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +201 -2
  3. package/dist/client.d.ts +78 -0
  4. package/dist/client.js +42 -0
  5. package/dist/errors.d.ts +20 -0
  6. package/dist/errors.js +55 -0
  7. package/dist/execute.d.ts +6 -0
  8. package/dist/execute.js +380 -0
  9. package/dist/gen/toshi/workspace/v1/artifacts_pb.d.ts +841 -0
  10. package/dist/gen/toshi/workspace/v1/artifacts_pb.js +96 -0
  11. package/dist/gen/toshi/workspace/v1/display_pb.d.ts +1142 -0
  12. package/dist/gen/toshi/workspace/v1/display_pb.js +214 -0
  13. package/dist/gen/toshi/workspace/v1/projects_pb.d.ts +1386 -0
  14. package/dist/gen/toshi/workspace/v1/projects_pb.js +207 -0
  15. package/dist/gen/toshi/workspace/v1/recordings_pb.d.ts +948 -0
  16. package/dist/gen/toshi/workspace/v1/recordings_pb.js +201 -0
  17. package/dist/gen/toshi/workspace/v1/secrets_pb.d.ts +1062 -0
  18. package/dist/gen/toshi/workspace/v1/secrets_pb.js +182 -0
  19. package/dist/gen/toshi/workspace/v1/volumes_pb.d.ts +406 -0
  20. package/dist/gen/toshi/workspace/v1/volumes_pb.js +107 -0
  21. package/dist/gen/toshi/workspace/v1/webhook_pb.d.ts +974 -0
  22. package/dist/gen/toshi/workspace/v1/webhook_pb.js +153 -0
  23. package/dist/gen/toshi/workspace/v1/workspace_pb.d.ts +6133 -0
  24. package/dist/gen/toshi/workspace/v1/workspace_pb.js +892 -0
  25. package/dist/index.d.ts +10 -0
  26. package/dist/index.js +9 -0
  27. package/dist/interceptors.d.ts +10 -0
  28. package/dist/interceptors.js +159 -0
  29. package/dist/output.d.ts +6 -0
  30. package/dist/output.js +77 -0
  31. package/dist/retry.d.ts +42 -0
  32. package/dist/retry.js +213 -0
  33. package/dist/stream.d.ts +21 -0
  34. package/dist/stream.js +84 -0
  35. package/dist/wait.d.ts +8 -0
  36. package/dist/wait.js +29 -0
  37. package/dist/watch.d.ts +8 -0
  38. package/dist/watch.js +224 -0
  39. package/package.json +47 -4
@@ -0,0 +1,10 @@
1
+ export * from "./gen/toshi/workspace/v1/workspace_pb.js";
2
+ export * from "./gen/toshi/workspace/v1/display_pb.js";
3
+ export * from "./gen/toshi/workspace/v1/projects_pb.js";
4
+ export * from "./gen/toshi/workspace/v1/secrets_pb.js";
5
+ export * from "./gen/toshi/workspace/v1/webhook_pb.js";
6
+ export { createToshiClient } from "./client.js";
7
+ export { ExitError, StreamLostError } from "./errors.js";
8
+ export { defaultRetryPolicy } from "./retry.js";
9
+ export type { RetryPolicy } from "./retry.js";
10
+ export type { CallOptions, EventWatch, OutputChunk, OutputWatch, Process, ToshiClient, ToshiOptions, WorkloadWatch, } from "./client.js";
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ // Generated code for the Toshi Workspace API; see src/gen/ and pin.env.
2
+ export * from "./gen/toshi/workspace/v1/workspace_pb.js";
3
+ export * from "./gen/toshi/workspace/v1/display_pb.js";
4
+ export * from "./gen/toshi/workspace/v1/projects_pb.js";
5
+ export * from "./gen/toshi/workspace/v1/secrets_pb.js";
6
+ export * from "./gen/toshi/workspace/v1/webhook_pb.js";
7
+ export { createToshiClient } from "./client.js";
8
+ export { ExitError, StreamLostError } from "./errors.js";
9
+ export { defaultRetryPolicy } from "./retry.js";
@@ -0,0 +1,10 @@
1
+ import type { Interceptor } from "@connectrpc/connect";
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;
5
+ /** Fills an empty project and environment from the options; caller values win (intercept.go:38-129). */
6
+ export declare function scopeInterceptor(project: string, environment: string): Interceptor;
7
+ /** Puts a request key on keyed procedures and on Execute's start (retry.go:110-116, 202-231). */
8
+ export declare function keyInterceptor(): Interceptor;
9
+ /** Retries a unary read or keyed write after a transport loss, with full jitter (retry.go:98-186). */
10
+ export declare function retryInterceptor(retry: RetrySetting): Interceptor;
@@ -0,0 +1,159 @@
1
+ import { ScalarType } from "@bufbuild/protobuf";
2
+ 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) {
5
+ if (token === "") {
6
+ return (next) => next;
7
+ }
8
+ return (next) => async (req) => {
9
+ req.header.set("Authorization", `Bearer ${token}`);
10
+ return next(req);
11
+ };
12
+ }
13
+ /** Fills an empty project and environment from the options; caller values win (intercept.go:38-129). */
14
+ export function scopeInterceptor(project, environment) {
15
+ if (project === "" && environment === "") {
16
+ return (next) => next;
17
+ }
18
+ return (next) => async (req) => {
19
+ const desc = req.method.input;
20
+ if (req.stream) {
21
+ return next({ ...req, message: fillStream(req.message, desc, project, environment) });
22
+ }
23
+ fillScope(req.message, desc, project, environment);
24
+ return next(req);
25
+ };
26
+ }
27
+ /** Puts a request key on keyed procedures and on Execute's start (retry.go:110-116, 202-231). */
28
+ export function keyInterceptor() {
29
+ return (next) => async (req) => {
30
+ const desc = req.method.input;
31
+ if (req.stream) {
32
+ // Only Execute is bidirectional; its start is the one stream message that
33
+ // carries a request key (execute.go, intercept.go:81-94).
34
+ if (req.method.methodKind !== "bidi_streaming") {
35
+ return next(req);
36
+ }
37
+ return next({ ...req, message: fillStreamKey(req.message, desc) });
38
+ }
39
+ if (retryClassFor(procedurePath(req.service, req.method)) === "keyed") {
40
+ fillRequestKey(req.message, desc);
41
+ }
42
+ return next(req);
43
+ };
44
+ }
45
+ /** Retries a unary read or keyed write after a transport loss, with full jitter (retry.go:98-186). */
46
+ export function retryInterceptor(retry) {
47
+ if (retry === false) {
48
+ return (next) => next;
49
+ }
50
+ const policy = retry;
51
+ return (next) => async (req) => {
52
+ // Streams are never retried here; the watches reconnect on their own (retry.go:135-141).
53
+ if (req.stream || retryClassFor(procedurePath(req.service, req.method)) === "none") {
54
+ return next(req);
55
+ }
56
+ // The same request object, with its key, is sent again (retry.go:118-130).
57
+ for (let attempt = 0;; attempt += 1) {
58
+ try {
59
+ return await next(req);
60
+ }
61
+ catch (err) {
62
+ if (attempt + 1 >= policy.maxAttempts || !isTransportLoss(err)) {
63
+ throw err;
64
+ }
65
+ await sleep(backoffDelay(policy, attempt), req.signal);
66
+ }
67
+ }
68
+ };
69
+ }
70
+ function fillStream(messages, desc, project, environment) {
71
+ return {
72
+ async *[Symbol.asyncIterator]() {
73
+ for await (const message of messages) {
74
+ fillScope(message, desc, project, environment);
75
+ yield message;
76
+ }
77
+ },
78
+ };
79
+ }
80
+ function fillStreamKey(messages, desc) {
81
+ return {
82
+ async *[Symbol.asyncIterator]() {
83
+ for await (const message of messages) {
84
+ fillRequestKey(message, desc);
85
+ yield message;
86
+ }
87
+ },
88
+ };
89
+ }
90
+ // Fills an empty project and, unless the message selects a shared set, an empty
91
+ // environment (intercept.go:102-123).
92
+ function fillScope(msg, desc, project, environment) {
93
+ if (project !== "") {
94
+ const field = stringField(desc, "project");
95
+ if (field !== undefined) {
96
+ fillIfEmpty(msg, field, project);
97
+ }
98
+ }
99
+ if (environment !== "" && !isShared(msg, desc)) {
100
+ const field = stringField(desc, "environment");
101
+ if (field !== undefined) {
102
+ fillIfEmpty(msg, field, environment);
103
+ }
104
+ }
105
+ for (const child of activeOneofMessages(msg, desc)) {
106
+ fillScope(child.message, child.desc, project, environment);
107
+ }
108
+ }
109
+ // Sets request_key on msg, or on the start message a bidi Execute request
110
+ // carries (retry.go:202-216).
111
+ function fillRequestKey(msg, desc) {
112
+ const field = stringField(desc, "request_key");
113
+ if (field !== undefined) {
114
+ const current = msg[field.localName];
115
+ if (current === "" || current === undefined) {
116
+ msg[field.localName] = newRequestKey();
117
+ }
118
+ return;
119
+ }
120
+ for (const child of activeOneofMessages(msg, desc)) {
121
+ fillRequestKey(child.message, child.desc);
122
+ }
123
+ }
124
+ // The active message-typed oneof values of msg; ExecuteRequest's start is one.
125
+ function* activeOneofMessages(msg, desc) {
126
+ for (const field of desc.fields) {
127
+ if (field.fieldKind !== "message" || field.oneof === undefined) {
128
+ continue;
129
+ }
130
+ const group = msg[field.oneof.localName];
131
+ if (typeof group !== "object" || group === null) {
132
+ continue;
133
+ }
134
+ const oneof = group;
135
+ if (oneof.case !== field.localName || typeof oneof.value !== "object" || oneof.value === null) {
136
+ continue;
137
+ }
138
+ yield { message: oneof.value, desc: field.message };
139
+ }
140
+ }
141
+ // A singular string field by its proto name, e.g. "request_key".
142
+ function stringField(desc, name) {
143
+ return desc.fields.find((field) => field.name === name && field.fieldKind === "scalar" && field.scalar === ScalarType.STRING);
144
+ }
145
+ // An unset proto3 string reads as "", so both it and an explicit "" are empty.
146
+ function fillIfEmpty(msg, field, value) {
147
+ const current = msg[field.localName];
148
+ if (current === "" || current === undefined) {
149
+ msg[field.localName] = value;
150
+ }
151
+ }
152
+ // Whether msg selects a shared secret set (intercept.go:125-129).
153
+ function isShared(msg, desc) {
154
+ const field = desc.fields.find((candidate) => candidate.name === "shared");
155
+ return (field !== undefined &&
156
+ field.fieldKind === "scalar" &&
157
+ field.scalar === ScalarType.BOOL &&
158
+ msg[field.localName] === true);
159
+ }
@@ -0,0 +1,6 @@
1
+ import type { Client } from "@connectrpc/connect";
2
+ import type { CallOptions, OutputWatch } from "./client.js";
3
+ import type { WatchOutputRequest, WorkspaceService } from "./gen/toshi/workspace/v1/workspace_pb.js";
4
+ import type { RetrySetting } from "./retry.js";
5
+ /** Starts an output watch at the request's offsets, counting the bytes it hands out (output.go:14-52). */
6
+ export declare function openOutputWatch(workspace: Client<typeof WorkspaceService>, retry: RetrySetting, req: WatchOutputRequest, opts?: CallOptions): Promise<OutputWatch>;
package/dist/output.js ADDED
@@ -0,0 +1,77 @@
1
+ import { clone } from "@bufbuild/protobuf";
2
+ import { WatchOutputRequestSchema } from "./gen/toshi/workspace/v1/workspace_pb.js";
3
+ import { READER_TAKEN, WatchState, createReader, reconnect, streamEnded } from "./stream.js";
4
+ /** Starts an output watch at the request's offsets, counting the bytes it hands out (output.go:14-52). */
5
+ export function openOutputWatch(workspace, retry, req, opts) {
6
+ const state = new WatchState("", opts?.signal);
7
+ const request = clone(WatchOutputRequestSchema, req);
8
+ let stdout = req.stdoutOffset;
9
+ let stderr = req.stderrOffset;
10
+ async function* run() {
11
+ let attempt = 0;
12
+ try {
13
+ for (;;) {
14
+ if (state.closed) {
15
+ return;
16
+ }
17
+ let cause;
18
+ try {
19
+ request.stdoutOffset = stdout;
20
+ request.stderrOffset = stderr;
21
+ const stream = workspace.watchOutput(request, { signal: state.signal });
22
+ for await (const msg of stream) {
23
+ attempt = 0;
24
+ if (msg.message.case === "end") {
25
+ // output.go:157-160: release the RPC before the caller sees the
26
+ // end message, and never reopen after it.
27
+ state.close();
28
+ yield msg;
29
+ return;
30
+ }
31
+ if (msg.message.case === "stdout") {
32
+ stdout += BigInt(msg.message.value.length);
33
+ }
34
+ else if (msg.message.case === "stderr") {
35
+ stderr += BigInt(msg.message.value.length);
36
+ }
37
+ yield msg;
38
+ }
39
+ if (state.closed) {
40
+ return;
41
+ }
42
+ cause = streamEnded;
43
+ }
44
+ catch (err) {
45
+ if (state.closed) {
46
+ return;
47
+ }
48
+ cause = err;
49
+ }
50
+ attempt = await reconnect(retry, attempt, cause, state);
51
+ }
52
+ }
53
+ catch (err) {
54
+ if (state.closed) {
55
+ return;
56
+ }
57
+ throw err;
58
+ }
59
+ }
60
+ let reader;
61
+ const watch = {
62
+ get offsets() {
63
+ return { stdout, stderr };
64
+ },
65
+ close() {
66
+ state.close();
67
+ },
68
+ [Symbol.asyncIterator]() {
69
+ if (reader !== undefined) {
70
+ throw new Error(READER_TAKEN);
71
+ }
72
+ reader = createReader(run, () => state.close());
73
+ return reader;
74
+ },
75
+ };
76
+ return Promise.resolve(watch);
77
+ }
@@ -0,0 +1,42 @@
1
+ /** How often and how long a call is retried; maxAttempts counts the first try. */
2
+ export interface RetryPolicy {
3
+ readonly maxAttempts: number;
4
+ readonly initialBackoffMs: number;
5
+ readonly maxBackoffMs: number;
6
+ }
7
+ /** Five attempts, a 200 ms initial backoff and a 10 s cap (options.go:79-85). */
8
+ export declare const defaultRetryPolicy: Readonly<RetryPolicy>;
9
+ /** A resolved retry setting; false turns retries and watch reconnects off. */
10
+ export type RetrySetting = Readonly<RetryPolicy> | false;
11
+ /** What one procedure's retries are: a read, a keyed write, or nothing. */
12
+ export type RetryClass = "read" | "keyed" | "none";
13
+ /** The retry class of a generated procedure path; an unknown path is "none". */
14
+ export declare function retryClassFor(procedure: string): RetryClass;
15
+ /** The `/<service typeName>/<method name>` path the client keys the retry table by. */
16
+ export declare function procedurePath(service: {
17
+ readonly typeName: string;
18
+ }, method: {
19
+ readonly name: string;
20
+ }): string;
21
+ /** Fills defaults and clamps the policy; false stays false. */
22
+ export declare function normalizeRetry(retry?: Partial<RetryPolicy> | false): RetrySetting;
23
+ /**
24
+ * True only for a dropped connection or an unavailable server: Code.Unavailable,
25
+ * or Code.Aborted caused by one of the Node codes above (retry.go:143-147).
26
+ * A close that matches neither is returned to the caller, not resumed.
27
+ */
28
+ export declare function isTransportLoss(err: unknown): boolean;
29
+ /**
30
+ * isTransportLoss, plus a Connect stream that ended without its EndStreamResponse
31
+ * (connect 2.2.0 protocol-connect/transport.js:198 throws InvalidArgument with
32
+ * this message); that case is a lost stream, not a server refusal.
33
+ */
34
+ export declare function isStreamLoss(err: unknown): boolean;
35
+ /** A uniform jittered delay in [0, min(maxBackoffMs, initialBackoffMs * 2**attempt)]. */
36
+ export declare function backoffDelay(policy: RetryPolicy, attempt: number): number;
37
+ /** Sleeps, rejecting at once when the signal aborts (retry.go:186-200). */
38
+ export declare function sleep(ms: number, signal?: AbortSignal): Promise<void>;
39
+ /** The signal's reason as an error, or a Canceled ConnectError. */
40
+ export declare function abortReason(signal: AbortSignal): Error;
41
+ /** A 128-bit request key as 32 lowercase hex characters (retry.go:226-231). */
42
+ export declare function newRequestKey(): string;
package/dist/retry.js ADDED
@@ -0,0 +1,213 @@
1
+ import { Code, ConnectError } from "@connectrpc/connect";
2
+ /** Five attempts, a 200 ms initial backoff and a 10 s cap (options.go:79-85). */
3
+ export const defaultRetryPolicy = {
4
+ maxAttempts: 5,
5
+ initialBackoffMs: 200,
6
+ maxBackoffMs: 10_000,
7
+ };
8
+ // One entry per procedure of the five services, verbatim from the Go client's retry table.
9
+ // A procedure missing from this table is treated as "none".
10
+ const procedureRetryClass = {
11
+ "/toshi.workspace.v1.WorkspaceService/CreateWorkload": "keyed",
12
+ "/toshi.workspace.v1.WorkspaceService/GetWorkload": "read",
13
+ "/toshi.workspace.v1.WorkspaceService/ListWorkloads": "read",
14
+ "/toshi.workspace.v1.WorkspaceService/PatchWorkload": "keyed",
15
+ "/toshi.workspace.v1.WorkspaceService/DeleteWorkload": "keyed",
16
+ "/toshi.workspace.v1.WorkspaceService/CancelWorkload": "none",
17
+ "/toshi.workspace.v1.WorkspaceService/WaitWorkload": "read",
18
+ "/toshi.workspace.v1.WorkspaceService/WatchWorkloads": "none",
19
+ "/toshi.workspace.v1.WorkspaceService/WatchOutput": "none",
20
+ "/toshi.workspace.v1.WorkspaceService/WatchTelemetry": "none",
21
+ "/toshi.workspace.v1.WorkspaceService/WatchGuestLog": "none",
22
+ "/toshi.workspace.v1.WorkspaceService/Execute": "none",
23
+ "/toshi.workspace.v1.WorkspaceService/CancelExecution": "keyed",
24
+ "/toshi.workspace.v1.WorkspaceService/GetOperation": "read",
25
+ "/toshi.workspace.v1.WorkspaceService/WaitOperation": "read",
26
+ "/toshi.workspace.v1.WorkspaceService/ListEvents": "read",
27
+ "/toshi.workspace.v1.WorkspaceService/WatchEvents": "none",
28
+ "/toshi.workspace.v1.WorkspaceService/CreatePreviewShareLink": "none",
29
+ "/toshi.workspace.v1.WorkspaceService/ListPreviewShareLinks": "read",
30
+ "/toshi.workspace.v1.WorkspaceService/RevokePreviewShareLink": "none",
31
+ "/toshi.workspace.v1.WorkspaceService/StartService": "none",
32
+ "/toshi.workspace.v1.WorkspaceService/StopService": "none",
33
+ "/toshi.workspace.v1.WorkspaceService/SetServiceMode": "none",
34
+ "/toshi.workspace.v1.WorkspaceService/WakeService": "none",
35
+ "/toshi.workspace.v1.WorkspaceService/HoldService": "none",
36
+ "/toshi.workspace.v1.WorkspaceService/ReleaseService": "none",
37
+ "/toshi.workspace.v1.WorkspaceService/ListServiceRuns": "read",
38
+ "/toshi.workspace.v1.SecretService/CreateSecretSet": "none",
39
+ "/toshi.workspace.v1.SecretService/ListSecretSets": "read",
40
+ "/toshi.workspace.v1.SecretService/DeleteSecretSet": "none",
41
+ "/toshi.workspace.v1.SecretService/SetSecret": "none",
42
+ "/toshi.workspace.v1.SecretService/DeleteSecret": "none",
43
+ "/toshi.workspace.v1.SecretService/ListSecrets": "read",
44
+ "/toshi.workspace.v1.SecretService/ListSecretVersions": "read",
45
+ "/toshi.workspace.v1.SecretService/GrantSecretSet": "none",
46
+ "/toshi.workspace.v1.SecretService/RevokeSecretSet": "none",
47
+ "/toshi.workspace.v1.SecretService/ListSecretSetGrants": "read",
48
+ "/toshi.workspace.v1.ProjectService/ListProjects": "read",
49
+ "/toshi.workspace.v1.ProjectService/ListRepositories": "read",
50
+ "/toshi.workspace.v1.ProjectService/GetProject": "read",
51
+ "/toshi.workspace.v1.ProjectService/ListBranchRules": "read",
52
+ "/toshi.workspace.v1.ProjectService/CreateProject": "none",
53
+ "/toshi.workspace.v1.ProjectService/ClaimRepository": "none",
54
+ "/toshi.workspace.v1.ProjectService/CreateEnvironment": "none",
55
+ "/toshi.workspace.v1.ProjectService/DeleteEnvironment": "none",
56
+ "/toshi.workspace.v1.ProjectService/SetBranchRules": "none",
57
+ "/toshi.workspace.v1.ProjectService/CreateEmbedToken": "none",
58
+ "/toshi.workspace.v1.ProjectService/ListEmbedTokens": "read",
59
+ "/toshi.workspace.v1.ProjectService/RevokeEmbedToken": "none",
60
+ "/toshi.workspace.v1.WebhookService/CreateWebhookEndpoint": "keyed",
61
+ "/toshi.workspace.v1.WebhookService/GetWebhookEndpoint": "read",
62
+ "/toshi.workspace.v1.WebhookService/ListWebhookEndpoints": "read",
63
+ "/toshi.workspace.v1.WebhookService/UpdateWebhookEndpoint": "keyed",
64
+ "/toshi.workspace.v1.WebhookService/DeleteWebhookEndpoint": "keyed",
65
+ "/toshi.workspace.v1.WebhookService/RotateWebhookEndpointSecret": "keyed",
66
+ "/toshi.workspace.v1.WebhookService/ListWebhookDeliveries": "read",
67
+ "/toshi.workspace.v1.WebhookService/ReplayWebhookEvents": "keyed",
68
+ "/toshi.workspace.v1.DisplayService/OpenDisplaySession": "none",
69
+ "/toshi.workspace.v1.DisplayService/GetDisplaySession": "read",
70
+ "/toshi.workspace.v1.DisplayService/ListDisplaySessions": "read",
71
+ "/toshi.workspace.v1.DisplayService/CloseDisplaySession": "none",
72
+ "/toshi.workspace.v1.DisplayService/SendDisplayKeys": "none",
73
+ "/toshi.workspace.v1.DisplayService/SendDisplayPointer": "none",
74
+ "/toshi.workspace.v1.DisplayService/SetDisplayClipboard": "none",
75
+ "/toshi.workspace.v1.DisplayService/TakeDisplayScreenshot": "none",
76
+ };
77
+ /** The retry class of a generated procedure path; an unknown path is "none". */
78
+ export function retryClassFor(procedure) {
79
+ return procedureRetryClass[procedure] ?? "none";
80
+ }
81
+ /** The `/<service typeName>/<method name>` path the client keys the retry table by. */
82
+ export function procedurePath(service, method) {
83
+ return `/${service.typeName}/${method.name}`;
84
+ }
85
+ /** Fills defaults and clamps the policy; false stays false. */
86
+ export function normalizeRetry(retry) {
87
+ if (retry === false) {
88
+ return false;
89
+ }
90
+ const merged = { ...defaultRetryPolicy, ...(retry ?? {}) };
91
+ return {
92
+ maxAttempts: Math.max(1, Math.trunc(merged.maxAttempts)),
93
+ initialBackoffMs: Math.max(0, merged.initialBackoffMs),
94
+ maxBackoffMs: Math.max(0, merged.maxBackoffMs),
95
+ };
96
+ }
97
+ // The Node system error codes connect-node 2.2.0 maps to Aborted or Unavailable
98
+ // in node-error.js:36-51: ERR_STREAM_DESTROYED, ERR_HTTP2_INVALID_STREAM,
99
+ // ECONNRESET (Aborted); ETIMEDOUT, ENOTFOUND, EAI_AGAIN, ECONNREFUSED (Unavailable).
100
+ // ERR_STREAM_WRITE_AFTER_END is left out: node-error.js:23-34 maps it to Aborted
101
+ // to say the server already answered with an error, so a retry repeats a refusal.
102
+ // An HTTP/2 stream reset reaches us as Code.Internal with Node's
103
+ // ERR_HTTP2_STREAM_ERROR, before connect's own reset mapping (node-error.js:105-125)
104
+ // runs. REFUSED_STREAM says the server never processed the stream, so it is a loss,
105
+ // as toshi-go's Unavailable for it is; every other reset code is returned.
106
+ const nodeLossCodes = new Set([
107
+ "ERR_STREAM_DESTROYED",
108
+ "ERR_HTTP2_INVALID_STREAM",
109
+ "ECONNRESET",
110
+ "ETIMEDOUT",
111
+ "ENOTFOUND",
112
+ "EAI_AGAIN",
113
+ "ECONNREFUSED",
114
+ ]);
115
+ // Whether the error chain carries one of those Node system error codes.
116
+ function hasNodeLossCause(cause) {
117
+ let current = cause;
118
+ const seen = new Set();
119
+ while (current instanceof Error && !seen.has(current)) {
120
+ seen.add(current);
121
+ if (nodeLossCodes.has(current.code)) {
122
+ return true;
123
+ }
124
+ current = current.cause;
125
+ }
126
+ return false;
127
+ }
128
+ // Whether the error chain carries Node's report of a REFUSED_STREAM reset.
129
+ function hasRefusedStreamCause(cause) {
130
+ let current = cause;
131
+ const seen = new Set();
132
+ while (current instanceof Error && !seen.has(current)) {
133
+ seen.add(current);
134
+ if (current.code === "ERR_HTTP2_STREAM_ERROR" &&
135
+ current.message.endsWith("NGHTTP2_REFUSED_STREAM")) {
136
+ return true;
137
+ }
138
+ current = current.cause;
139
+ }
140
+ return false;
141
+ }
142
+ /**
143
+ * True only for a dropped connection or an unavailable server: Code.Unavailable,
144
+ * or Code.Aborted caused by one of the Node codes above (retry.go:143-147).
145
+ * A close that matches neither is returned to the caller, not resumed.
146
+ */
147
+ export function isTransportLoss(err) {
148
+ if (!(err instanceof ConnectError)) {
149
+ return false;
150
+ }
151
+ if (err.code === Code.Unavailable) {
152
+ return true;
153
+ }
154
+ if (err.code === Code.Internal) {
155
+ return hasRefusedStreamCause(err.cause);
156
+ }
157
+ return err.code === Code.Aborted && hasNodeLossCause(err.cause);
158
+ }
159
+ /**
160
+ * isTransportLoss, plus a Connect stream that ended without its EndStreamResponse
161
+ * (connect 2.2.0 protocol-connect/transport.js:198 throws InvalidArgument with
162
+ * this message); that case is a lost stream, not a server refusal.
163
+ */
164
+ export function isStreamLoss(err) {
165
+ return (isTransportLoss(err) ||
166
+ (err instanceof ConnectError &&
167
+ err.code === Code.InvalidArgument &&
168
+ err.rawMessage === "protocol error: missing EndStreamResponse"));
169
+ }
170
+ /** A uniform jittered delay in [0, min(maxBackoffMs, initialBackoffMs * 2**attempt)]. */
171
+ export function backoffDelay(policy, attempt) {
172
+ const limit = Math.min(policy.maxBackoffMs, policy.initialBackoffMs * 2 ** attempt);
173
+ if (limit <= 0) {
174
+ return 0;
175
+ }
176
+ const random = globalThis.crypto.getRandomValues(new Uint32Array(1))[0] / 2 ** 32;
177
+ return Math.floor(random * (limit + 1));
178
+ }
179
+ /** Sleeps, rejecting at once when the signal aborts (retry.go:186-200). */
180
+ export function sleep(ms, signal) {
181
+ return new Promise((resolve, reject) => {
182
+ if (signal?.aborted === true) {
183
+ reject(abortReason(signal));
184
+ return;
185
+ }
186
+ const onAbort = () => {
187
+ clearTimeout(timer);
188
+ reject(abortReason(signal));
189
+ };
190
+ const timer = setTimeout(() => {
191
+ signal?.removeEventListener("abort", onAbort);
192
+ resolve();
193
+ }, ms);
194
+ signal?.addEventListener("abort", onAbort, { once: true });
195
+ });
196
+ }
197
+ /** The signal's reason as an error, or a Canceled ConnectError. */
198
+ export function abortReason(signal) {
199
+ const reason = signal.reason;
200
+ if (reason instanceof Error) {
201
+ return reason;
202
+ }
203
+ return new ConnectError("the call was cancelled", Code.Canceled);
204
+ }
205
+ /** A 128-bit request key as 32 lowercase hex characters (retry.go:226-231). */
206
+ export function newRequestKey() {
207
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
208
+ let key = "";
209
+ for (const byte of bytes) {
210
+ key += byte.toString(16).padStart(2, "0");
211
+ }
212
+ return key;
213
+ }
@@ -0,0 +1,21 @@
1
+ import type { RetrySetting } from "./retry.js";
2
+ /** The one-reader guard every watch shares. */
3
+ export declare const READER_TAKEN = "toshi: this watch already has a reader";
4
+ export declare const streamEnded: Error;
5
+ interface AsyncGeneratorFactory<T> {
6
+ (): AsyncGenerator<T, void, undefined>;
7
+ }
8
+ export declare function createReader<T>(start: AsyncGeneratorFactory<T>, close: () => void): AsyncIterableIterator<T>;
9
+ export declare function reconnect(retry: RetrySetting, attempt: number, cause: unknown, state: WatchState): Promise<number>;
10
+ export declare class WatchState {
11
+ /** The caller-visible resume point; the output watch passes "" and ignores it. */
12
+ cursor: string;
13
+ closed: boolean;
14
+ readonly signal: AbortSignal;
15
+ private readonly controller;
16
+ private readonly caller;
17
+ private readonly onCallerAbort;
18
+ constructor(initialCursor: string, caller: AbortSignal | undefined);
19
+ close(): void;
20
+ }
21
+ export {};
package/dist/stream.js ADDED
@@ -0,0 +1,84 @@
1
+ import { abortReason, backoffDelay, isStreamLoss, sleep } from "./retry.js";
2
+ /** The one-reader guard every watch shares. */
3
+ export const READER_TAKEN = "toshi: this watch already has a reader";
4
+ // watch.go:473-476, output.go:120-127: a stream that ends without its end
5
+ // message is a loss, so it is retried like one and reported with Go's wording
6
+ // once no attempt remains.
7
+ export const streamEnded = new Error("toshi: stream ended without an error");
8
+ // connect's generated client exposes only next() (promise-client.ts:201-215), so
9
+ // the watch owns the release: close() aborts the controller, and the reader's
10
+ // return() does the same before ending the iteration.
11
+ export function createReader(start, close) {
12
+ const generator = start();
13
+ return {
14
+ // Every end, an error included, releases the watch: a rejected next() is
15
+ // not followed by return(), so the caller's abort listener goes here.
16
+ next() {
17
+ return generator.next().then((result) => {
18
+ if (result.done === true) {
19
+ close();
20
+ }
21
+ return result;
22
+ }, (err) => {
23
+ close();
24
+ throw err;
25
+ });
26
+ },
27
+ async return() {
28
+ close();
29
+ await generator.return(undefined);
30
+ return { done: true, value: undefined };
31
+ },
32
+ [Symbol.asyncIterator]() {
33
+ return this;
34
+ },
35
+ };
36
+ }
37
+ // watch.go:104-118, output.go:74-86: maxAttempts - 1 reconnects in a row, the
38
+ // same jittered backoff, and the count reset by every message received. Anything
39
+ // that is not a lost stream ends the iteration with that error.
40
+ export async function reconnect(retry, attempt, cause, state) {
41
+ const lost = cause === streamEnded || isStreamLoss(cause);
42
+ if (retry === false || !lost || attempt >= retry.maxAttempts - 1) {
43
+ throw cause;
44
+ }
45
+ await sleep(backoffDelay(retry, attempt), state.signal);
46
+ return attempt + 1;
47
+ }
48
+ // Owns the AbortController that cancels the open RPC, a pending next() and any
49
+ // 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
+ export class WatchState {
52
+ /** The caller-visible resume point; the output watch passes "" and ignores it. */
53
+ cursor;
54
+ closed = false;
55
+ signal;
56
+ controller = new AbortController();
57
+ caller;
58
+ onCallerAbort = () => {
59
+ if (this.caller !== undefined) {
60
+ this.controller.abort(abortReason(this.caller));
61
+ }
62
+ };
63
+ constructor(initialCursor, caller) {
64
+ this.cursor = initialCursor;
65
+ this.caller = caller;
66
+ this.signal = this.controller.signal;
67
+ if (caller !== undefined) {
68
+ if (caller.aborted) {
69
+ this.onCallerAbort();
70
+ }
71
+ else {
72
+ caller.addEventListener("abort", this.onCallerAbort, { once: true });
73
+ }
74
+ }
75
+ }
76
+ close() {
77
+ if (this.closed) {
78
+ return;
79
+ }
80
+ this.closed = true;
81
+ this.caller?.removeEventListener("abort", this.onCallerAbort);
82
+ this.controller.abort();
83
+ }
84
+ }
package/dist/wait.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { Client } from "@connectrpc/connect";
2
+ import type { CallOptions } from "./client.js";
3
+ import type { WorkspaceService } from "./gen/toshi/workspace/v1/workspace_pb.js";
4
+ import type { Operation, WaitOperationRequest, WaitWorkloadRequest, WaitWorkloadResponse } from "./gen/toshi/workspace/v1/workspace_pb.js";
5
+ /** Asks again while reached is false, and returns the response when it is (wait.go:11-27). */
6
+ export declare function waitWorkload(workspace: Client<typeof WorkspaceService>, req: WaitWorkloadRequest, opts?: CallOptions): Promise<WaitWorkloadResponse>;
7
+ /** Asks again while the operation is not done, and returns it when it is (wait.go:29-45). */
8
+ export declare function waitOperation(workspace: Client<typeof WorkspaceService>, req: WaitOperationRequest, opts?: CallOptions): Promise<Operation>;