@relayfile/sdk 0.5.2 → 0.6.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/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/setup-errors.d.ts +44 -0
- package/dist/setup-errors.js +79 -0
- package/dist/setup-types.d.ts +54 -0
- package/dist/setup-types.js +8 -0
- package/dist/setup.d.ts +64 -0
- package/dist/setup.js +524 -0
- package/package.json +3 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export { RelayFileClient, DEFAULT_RELAYFILE_BASE_URL, type AccessTokenProvider, type ConnectWebSocketOptions, type RelayFileClientOptions, type RelayFileRetryOptions, type WebSocketConnection } from "./client.js";
|
|
2
|
+
export { RelayfileSetup, WorkspaceHandle } from "./setup.js";
|
|
3
|
+
export { CloudAbortError, CloudApiError, CloudTimeoutError, IntegrationConnectionTimeoutError, MalformedCloudResponseError, MissingConnectionIdError, RelayfileSetupError, UnknownProviderError } from "./setup-errors.js";
|
|
4
|
+
export { WORKSPACE_INTEGRATION_PROVIDERS, type ConnectIntegrationOptions, type ConnectIntegrationResult, type CreateWorkspaceOptions, type JoinWorkspaceOptions, type RelayfileSetupOptions, type RelayfileSetupRetryOptions, type WaitForConnectionOptions, type WorkspaceInfo, type WorkspaceIntegrationProvider, type WorkspacePermissions } from "./setup-types.js";
|
|
2
5
|
export { RelayFileSync, type RelayFileSyncOptions, type RelayFileSyncPong, type RelayFileSyncReconnectOptions, type RelayFileSyncSocket, type RelayFileSyncState } from "./sync.js";
|
|
3
6
|
export { InvalidStateError, PayloadTooLargeError, QueueFullError, RelayFileApiError, RevisionConflictError } from "./errors.js";
|
|
4
7
|
export { IntegrationProvider, computeCanonicalPath } from "./provider.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export { RelayFileClient, DEFAULT_RELAYFILE_BASE_URL } from "./client.js";
|
|
2
|
+
export { RelayfileSetup, WorkspaceHandle } from "./setup.js";
|
|
3
|
+
export { CloudAbortError, CloudApiError, CloudTimeoutError, IntegrationConnectionTimeoutError, MalformedCloudResponseError, MissingConnectionIdError, RelayfileSetupError, UnknownProviderError } from "./setup-errors.js";
|
|
4
|
+
export { WORKSPACE_INTEGRATION_PROVIDERS } from "./setup-types.js";
|
|
2
5
|
export { RelayFileSync } from "./sync.js";
|
|
3
6
|
export { InvalidStateError, PayloadTooLargeError, QueueFullError, RelayFileApiError, RevisionConflictError } from "./errors.js";
|
|
4
7
|
// Integration providers
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { WorkspaceIntegrationProvider } from "./setup-types.js";
|
|
2
|
+
export declare class RelayfileSetupError extends Error {
|
|
3
|
+
readonly code: string;
|
|
4
|
+
constructor(message: string, code: string);
|
|
5
|
+
}
|
|
6
|
+
export declare class CloudApiError extends RelayfileSetupError {
|
|
7
|
+
readonly httpStatus: number;
|
|
8
|
+
readonly httpBody: unknown;
|
|
9
|
+
constructor(httpStatus: number, httpBody: unknown, message?: string);
|
|
10
|
+
}
|
|
11
|
+
export declare class MalformedCloudResponseError extends RelayfileSetupError {
|
|
12
|
+
readonly field: string;
|
|
13
|
+
readonly response: unknown;
|
|
14
|
+
constructor(field: string, response: unknown);
|
|
15
|
+
}
|
|
16
|
+
export declare class CloudTimeoutError extends RelayfileSetupError {
|
|
17
|
+
readonly operation: string;
|
|
18
|
+
readonly timeoutMs: number;
|
|
19
|
+
constructor(operation: string, timeoutMs: number);
|
|
20
|
+
}
|
|
21
|
+
export declare class IntegrationConnectionTimeoutError extends RelayfileSetupError {
|
|
22
|
+
readonly provider: WorkspaceIntegrationProvider;
|
|
23
|
+
readonly connectionId: string;
|
|
24
|
+
readonly elapsedMs: number;
|
|
25
|
+
readonly timeoutMs: number;
|
|
26
|
+
constructor(input: {
|
|
27
|
+
provider: WorkspaceIntegrationProvider;
|
|
28
|
+
connectionId: string;
|
|
29
|
+
elapsedMs: number;
|
|
30
|
+
timeoutMs: number;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export declare class CloudAbortError extends RelayfileSetupError {
|
|
34
|
+
readonly operation: string;
|
|
35
|
+
constructor(operation: string);
|
|
36
|
+
}
|
|
37
|
+
export declare class UnknownProviderError extends RelayfileSetupError {
|
|
38
|
+
readonly provider: string;
|
|
39
|
+
constructor(provider: string);
|
|
40
|
+
}
|
|
41
|
+
export declare class MissingConnectionIdError extends RelayfileSetupError {
|
|
42
|
+
readonly provider: WorkspaceIntegrationProvider;
|
|
43
|
+
constructor(provider: WorkspaceIntegrationProvider);
|
|
44
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export class RelayfileSetupError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
constructor(message, code) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = new.target.name;
|
|
6
|
+
this.code = code;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export class CloudApiError extends RelayfileSetupError {
|
|
10
|
+
httpStatus;
|
|
11
|
+
httpBody;
|
|
12
|
+
constructor(httpStatus, httpBody, message) {
|
|
13
|
+
super(message ??
|
|
14
|
+
readCloudErrorMessage(httpBody) ??
|
|
15
|
+
`Cloud API request failed with status ${httpStatus}.`, "cloud_api_error");
|
|
16
|
+
this.httpStatus = httpStatus;
|
|
17
|
+
this.httpBody = httpBody;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class MalformedCloudResponseError extends RelayfileSetupError {
|
|
21
|
+
field;
|
|
22
|
+
response;
|
|
23
|
+
constructor(field, response) {
|
|
24
|
+
super(`Cloud API response is missing required field "${field}".`, "malformed_cloud_response");
|
|
25
|
+
this.field = field;
|
|
26
|
+
this.response = response;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export class CloudTimeoutError extends RelayfileSetupError {
|
|
30
|
+
operation;
|
|
31
|
+
timeoutMs;
|
|
32
|
+
constructor(operation, timeoutMs) {
|
|
33
|
+
super(`Timed out while waiting for ${operation} after ${timeoutMs}ms.`, "cloud_timeout_error");
|
|
34
|
+
this.operation = operation;
|
|
35
|
+
this.timeoutMs = timeoutMs;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export class IntegrationConnectionTimeoutError extends RelayfileSetupError {
|
|
39
|
+
provider;
|
|
40
|
+
connectionId;
|
|
41
|
+
elapsedMs;
|
|
42
|
+
timeoutMs;
|
|
43
|
+
constructor(input) {
|
|
44
|
+
super(`Timed out waiting for ${input.provider} connection "${input.connectionId}" after ${input.elapsedMs}ms.`, "integration_connection_timeout");
|
|
45
|
+
this.provider = input.provider;
|
|
46
|
+
this.connectionId = input.connectionId;
|
|
47
|
+
this.elapsedMs = input.elapsedMs;
|
|
48
|
+
this.timeoutMs = input.timeoutMs;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export class CloudAbortError extends RelayfileSetupError {
|
|
52
|
+
operation;
|
|
53
|
+
constructor(operation) {
|
|
54
|
+
super(`Aborted while waiting for ${operation}.`, "cloud_abort_error");
|
|
55
|
+
this.operation = operation;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export class UnknownProviderError extends RelayfileSetupError {
|
|
59
|
+
provider;
|
|
60
|
+
constructor(provider) {
|
|
61
|
+
super(`Unknown workspace integration provider "${provider}".`, "unknown_provider");
|
|
62
|
+
this.provider = provider;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export class MissingConnectionIdError extends RelayfileSetupError {
|
|
66
|
+
provider;
|
|
67
|
+
constructor(provider) {
|
|
68
|
+
super(`No connectionId is available for provider "${provider}".`, "missing_connection_id");
|
|
69
|
+
this.provider = provider;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function readCloudErrorMessage(httpBody) {
|
|
73
|
+
if (!httpBody || typeof httpBody !== "object" || Array.isArray(httpBody)) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
const data = httpBody;
|
|
77
|
+
const message = data.message ?? data.error;
|
|
78
|
+
return typeof message === "string" && message.trim() !== "" ? message : undefined;
|
|
79
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { AccessTokenProvider } from "./client.js";
|
|
2
|
+
export declare const WORKSPACE_INTEGRATION_PROVIDERS: readonly ["github", "slack-sage", "slack-my-senior-dev", "slack-nightcto", "notion", "linear"];
|
|
3
|
+
export type WorkspaceIntegrationProvider = (typeof WORKSPACE_INTEGRATION_PROVIDERS)[number];
|
|
4
|
+
export interface WorkspacePermissions {
|
|
5
|
+
readonly?: string[];
|
|
6
|
+
ignored?: string[];
|
|
7
|
+
}
|
|
8
|
+
export interface RelayfileSetupRetryOptions {
|
|
9
|
+
maxRetries: number;
|
|
10
|
+
baseDelayMs: number;
|
|
11
|
+
}
|
|
12
|
+
export interface RelayfileSetupOptions {
|
|
13
|
+
cloudApiUrl?: string;
|
|
14
|
+
accessToken?: AccessTokenProvider;
|
|
15
|
+
requestTimeoutMs?: number;
|
|
16
|
+
retry?: RelayfileSetupRetryOptions;
|
|
17
|
+
}
|
|
18
|
+
export interface CreateWorkspaceOptions {
|
|
19
|
+
name?: string;
|
|
20
|
+
permissions?: WorkspacePermissions;
|
|
21
|
+
agentName?: string;
|
|
22
|
+
scopes?: string[];
|
|
23
|
+
}
|
|
24
|
+
export interface JoinWorkspaceOptions {
|
|
25
|
+
agentName?: string;
|
|
26
|
+
scopes?: string[];
|
|
27
|
+
permissions?: WorkspacePermissions;
|
|
28
|
+
}
|
|
29
|
+
export interface WorkspaceInfo {
|
|
30
|
+
workspaceId: string;
|
|
31
|
+
relayfileUrl: string;
|
|
32
|
+
relaycastApiKey: string;
|
|
33
|
+
createdAt?: string;
|
|
34
|
+
name?: string;
|
|
35
|
+
wsUrl?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface ConnectIntegrationOptions {
|
|
38
|
+
connectionId?: string;
|
|
39
|
+
allowedIntegrations?: string[];
|
|
40
|
+
}
|
|
41
|
+
export interface ConnectIntegrationResult {
|
|
42
|
+
connectLink: string | null;
|
|
43
|
+
sessionToken: string | null;
|
|
44
|
+
expiresAt: string | null;
|
|
45
|
+
alreadyConnected: boolean;
|
|
46
|
+
connectionId: string;
|
|
47
|
+
}
|
|
48
|
+
export interface WaitForConnectionOptions {
|
|
49
|
+
connectionId?: string;
|
|
50
|
+
intervalMs?: number;
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
signal?: AbortSignal;
|
|
53
|
+
onPoll?: (attempt: number, ready: boolean) => void;
|
|
54
|
+
}
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { RelayFileClient, type AccessTokenProvider } from "./client.js";
|
|
2
|
+
import { type ConnectIntegrationOptions, type ConnectIntegrationResult, type CreateWorkspaceOptions, type JoinWorkspaceOptions, type RelayfileSetupOptions, type WaitForConnectionOptions, type WorkspaceInfo, type WorkspaceIntegrationProvider, type WorkspacePermissions } from "./setup-types.js";
|
|
3
|
+
interface JoinWorkspaceResponse {
|
|
4
|
+
workspaceId?: string;
|
|
5
|
+
token?: string;
|
|
6
|
+
relayfileUrl?: string;
|
|
7
|
+
wsUrl?: string;
|
|
8
|
+
relaycastApiKey?: string;
|
|
9
|
+
}
|
|
10
|
+
interface NormalizedJoinWorkspaceOptions {
|
|
11
|
+
agentName: string;
|
|
12
|
+
scopes: string[];
|
|
13
|
+
permissions?: WorkspacePermissions;
|
|
14
|
+
}
|
|
15
|
+
interface CloudRequestOptions {
|
|
16
|
+
operation: string;
|
|
17
|
+
method: string;
|
|
18
|
+
path: string;
|
|
19
|
+
body?: unknown;
|
|
20
|
+
signal?: AbortSignal;
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
tokenProvider?: AccessTokenProvider;
|
|
23
|
+
}
|
|
24
|
+
interface WorkspaceHandleOptions {
|
|
25
|
+
setup: RelayfileSetup;
|
|
26
|
+
info: WorkspaceInfo;
|
|
27
|
+
token: string;
|
|
28
|
+
joinOptions: NormalizedJoinWorkspaceOptions;
|
|
29
|
+
}
|
|
30
|
+
export declare class RelayfileSetup {
|
|
31
|
+
private readonly cloudApiUrl;
|
|
32
|
+
private readonly accessToken?;
|
|
33
|
+
private readonly requestTimeoutMs;
|
|
34
|
+
private readonly retryOptions;
|
|
35
|
+
constructor(options?: RelayfileSetupOptions);
|
|
36
|
+
createWorkspace(options?: CreateWorkspaceOptions): Promise<WorkspaceHandle>;
|
|
37
|
+
joinWorkspace(workspaceId: string, options?: JoinWorkspaceOptions): Promise<WorkspaceHandle>;
|
|
38
|
+
joinWorkspaceResponse(workspaceId: string, options: NormalizedJoinWorkspaceOptions): Promise<Required<JoinWorkspaceResponse>>;
|
|
39
|
+
requestJson(options: CloudRequestOptions): Promise<unknown>;
|
|
40
|
+
}
|
|
41
|
+
export declare class WorkspaceHandle {
|
|
42
|
+
readonly info: WorkspaceInfo;
|
|
43
|
+
readonly workspaceId: string;
|
|
44
|
+
private readonly _setup;
|
|
45
|
+
private readonly _joinOptions;
|
|
46
|
+
private readonly _pendingConnections;
|
|
47
|
+
private _token;
|
|
48
|
+
private _tokenIssuedAt;
|
|
49
|
+
private _client?;
|
|
50
|
+
private _refreshPromise?;
|
|
51
|
+
constructor(options: WorkspaceHandleOptions);
|
|
52
|
+
client(): RelayFileClient;
|
|
53
|
+
connectIntegration(provider: WorkspaceIntegrationProvider, options?: ConnectIntegrationOptions): Promise<ConnectIntegrationResult>;
|
|
54
|
+
waitForConnection(provider: WorkspaceIntegrationProvider, options?: WaitForConnectionOptions): Promise<void>;
|
|
55
|
+
isConnected(provider: WorkspaceIntegrationProvider, connectionId: string): Promise<boolean>;
|
|
56
|
+
disconnectIntegration(provider: WorkspaceIntegrationProvider, _connectionId?: string): Promise<void>;
|
|
57
|
+
getToken(): string;
|
|
58
|
+
refreshToken(): Promise<void>;
|
|
59
|
+
private performRefreshToken;
|
|
60
|
+
private getOrRefreshToken;
|
|
61
|
+
private resolveConnectionId;
|
|
62
|
+
private getConnectionStatus;
|
|
63
|
+
}
|
|
64
|
+
export {};
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import { RelayFileClient } from "./client.js";
|
|
2
|
+
import { CloudAbortError, CloudApiError, CloudTimeoutError, IntegrationConnectionTimeoutError, MalformedCloudResponseError, MissingConnectionIdError, UnknownProviderError } from "./setup-errors.js";
|
|
3
|
+
import { WORKSPACE_INTEGRATION_PROVIDERS } from "./setup-types.js";
|
|
4
|
+
const DEFAULT_CLOUD_API_URL = "https://agentrelay.com/cloud";
|
|
5
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
6
|
+
const DEFAULT_RETRY_BASE_DELAY_MS = 500;
|
|
7
|
+
const DEFAULT_RETRY_MAX_DELAY_MS = 5_000;
|
|
8
|
+
const DEFAULT_RETRY_MAX_RETRIES = 3;
|
|
9
|
+
const DEFAULT_AGENT_NAME = "sdk-agent";
|
|
10
|
+
const DEFAULT_SCOPES = ["fs:read", "fs:write"];
|
|
11
|
+
const DEFAULT_WAIT_INTERVAL_MS = 2_000;
|
|
12
|
+
const DEFAULT_WAIT_TIMEOUT_MS = 300_000;
|
|
13
|
+
const TOKEN_REFRESH_AGE_MS = 55 * 60 * 1000;
|
|
14
|
+
export class RelayfileSetup {
|
|
15
|
+
cloudApiUrl;
|
|
16
|
+
accessToken;
|
|
17
|
+
requestTimeoutMs;
|
|
18
|
+
retryOptions;
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
this.cloudApiUrl = options.cloudApiUrl ?? DEFAULT_CLOUD_API_URL;
|
|
21
|
+
this.accessToken = options.accessToken;
|
|
22
|
+
this.requestTimeoutMs = Math.max(1, Math.floor(options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS));
|
|
23
|
+
this.retryOptions = normalizeRetryOptions(options.retry);
|
|
24
|
+
}
|
|
25
|
+
async createWorkspace(options = {}) {
|
|
26
|
+
const createResponse = validateCreateWorkspaceResponse(await this.requestJson({
|
|
27
|
+
operation: "createWorkspace",
|
|
28
|
+
method: "POST",
|
|
29
|
+
path: "api/v1/workspaces",
|
|
30
|
+
body: compactObject({
|
|
31
|
+
name: options.name,
|
|
32
|
+
permissions: clonePermissions(options.permissions)
|
|
33
|
+
})
|
|
34
|
+
}));
|
|
35
|
+
const joinOptions = normalizeJoinWorkspaceOptions({
|
|
36
|
+
agentName: options.agentName,
|
|
37
|
+
scopes: options.scopes,
|
|
38
|
+
permissions: options.permissions
|
|
39
|
+
});
|
|
40
|
+
const joinResponse = await this.joinWorkspaceResponse(createResponse.workspaceId, joinOptions);
|
|
41
|
+
return new WorkspaceHandle({
|
|
42
|
+
setup: this,
|
|
43
|
+
info: {
|
|
44
|
+
workspaceId: createResponse.workspaceId,
|
|
45
|
+
relayfileUrl: joinResponse.relayfileUrl,
|
|
46
|
+
relaycastApiKey: joinResponse.relaycastApiKey,
|
|
47
|
+
createdAt: createResponse.createdAt,
|
|
48
|
+
name: createResponse.name,
|
|
49
|
+
wsUrl: joinResponse.wsUrl
|
|
50
|
+
},
|
|
51
|
+
token: joinResponse.token,
|
|
52
|
+
joinOptions
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
async joinWorkspace(workspaceId, options = {}) {
|
|
56
|
+
const joinOptions = normalizeJoinWorkspaceOptions(options);
|
|
57
|
+
const joinResponse = await this.joinWorkspaceResponse(workspaceId, joinOptions);
|
|
58
|
+
return new WorkspaceHandle({
|
|
59
|
+
setup: this,
|
|
60
|
+
info: {
|
|
61
|
+
workspaceId: joinResponse.workspaceId,
|
|
62
|
+
relayfileUrl: joinResponse.relayfileUrl,
|
|
63
|
+
relaycastApiKey: joinResponse.relaycastApiKey,
|
|
64
|
+
wsUrl: joinResponse.wsUrl
|
|
65
|
+
},
|
|
66
|
+
token: joinResponse.token,
|
|
67
|
+
joinOptions
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
async joinWorkspaceResponse(workspaceId, options) {
|
|
71
|
+
return validateJoinWorkspaceResponse(await this.requestJson({
|
|
72
|
+
operation: "joinWorkspace",
|
|
73
|
+
method: "POST",
|
|
74
|
+
path: `api/v1/workspaces/${encodeURIComponent(workspaceId)}/join`,
|
|
75
|
+
body: compactObject({
|
|
76
|
+
agentName: options.agentName,
|
|
77
|
+
scopes: [...options.scopes],
|
|
78
|
+
permissions: clonePermissions(options.permissions)
|
|
79
|
+
})
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
async requestJson(options) {
|
|
83
|
+
const url = buildCloudUrl(this.cloudApiUrl, options.path);
|
|
84
|
+
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
|
|
85
|
+
let retries = 0;
|
|
86
|
+
for (;;) {
|
|
87
|
+
const token = await resolveToken(options.tokenProvider ?? this.accessToken);
|
|
88
|
+
const headers = {};
|
|
89
|
+
if (token) {
|
|
90
|
+
headers.Authorization = `Bearer ${token}`;
|
|
91
|
+
}
|
|
92
|
+
if (body !== undefined) {
|
|
93
|
+
headers["Content-Type"] = "application/json";
|
|
94
|
+
}
|
|
95
|
+
let response;
|
|
96
|
+
try {
|
|
97
|
+
response = await fetchWithTimeout(url, {
|
|
98
|
+
method: options.method,
|
|
99
|
+
headers,
|
|
100
|
+
body,
|
|
101
|
+
signal: options.signal
|
|
102
|
+
}, options.timeoutMs ?? this.requestTimeoutMs, options.operation);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (error instanceof CloudAbortError ||
|
|
106
|
+
error instanceof CloudTimeoutError) {
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
if (!shouldRetryError(error, retries, this.retryOptions.maxRetries, options.signal)) {
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
retries += 1;
|
|
113
|
+
await sleep(computeRetryDelayMs(this.retryOptions, retries, null), options.signal, options.operation);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const payload = await readResponseBody(response);
|
|
117
|
+
if (response.ok) {
|
|
118
|
+
return payload;
|
|
119
|
+
}
|
|
120
|
+
if (shouldRetryStatus(response.status, retries, this.retryOptions.maxRetries, options.signal)) {
|
|
121
|
+
retries += 1;
|
|
122
|
+
await sleep(computeRetryDelayMs(this.retryOptions, retries, response.headers.get("retry-after")), options.signal, options.operation);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
throw new CloudApiError(response.status, payload);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export class WorkspaceHandle {
|
|
130
|
+
info;
|
|
131
|
+
workspaceId;
|
|
132
|
+
_setup;
|
|
133
|
+
_joinOptions;
|
|
134
|
+
_pendingConnections = new Map();
|
|
135
|
+
_token;
|
|
136
|
+
_tokenIssuedAt;
|
|
137
|
+
_client;
|
|
138
|
+
_refreshPromise;
|
|
139
|
+
constructor(options) {
|
|
140
|
+
this.info = options.info;
|
|
141
|
+
this.workspaceId = options.info.workspaceId;
|
|
142
|
+
this._setup = options.setup;
|
|
143
|
+
this._joinOptions = {
|
|
144
|
+
agentName: options.joinOptions.agentName,
|
|
145
|
+
scopes: [...options.joinOptions.scopes],
|
|
146
|
+
permissions: clonePermissions(options.joinOptions.permissions)
|
|
147
|
+
};
|
|
148
|
+
this._token = options.token;
|
|
149
|
+
this._tokenIssuedAt = Date.now();
|
|
150
|
+
}
|
|
151
|
+
client() {
|
|
152
|
+
if (!this._client) {
|
|
153
|
+
this._client = new RelayFileClient({
|
|
154
|
+
baseUrl: this.info.relayfileUrl,
|
|
155
|
+
token: async () => this.getOrRefreshToken()
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return this._client;
|
|
159
|
+
}
|
|
160
|
+
async connectIntegration(provider, options = {}) {
|
|
161
|
+
assertProvider(provider);
|
|
162
|
+
const requestedConnectionId = normalizeConnectionId(options.connectionId);
|
|
163
|
+
if (requestedConnectionId) {
|
|
164
|
+
const alreadyConnected = await this.isConnected(provider, requestedConnectionId);
|
|
165
|
+
if (alreadyConnected) {
|
|
166
|
+
this._pendingConnections.set(provider, requestedConnectionId);
|
|
167
|
+
return {
|
|
168
|
+
alreadyConnected: true,
|
|
169
|
+
connectLink: null,
|
|
170
|
+
sessionToken: null,
|
|
171
|
+
expiresAt: null,
|
|
172
|
+
connectionId: requestedConnectionId
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const response = validateConnectSessionResponse(await this._setup.requestJson({
|
|
177
|
+
operation: "connectIntegration",
|
|
178
|
+
method: "POST",
|
|
179
|
+
path: `api/v1/workspaces/${encodeURIComponent(this.workspaceId)}/integrations/connect-session`,
|
|
180
|
+
body: {
|
|
181
|
+
allowedIntegrations: options.allowedIntegrations && options.allowedIntegrations.length > 0
|
|
182
|
+
? [...options.allowedIntegrations]
|
|
183
|
+
: [provider]
|
|
184
|
+
},
|
|
185
|
+
tokenProvider: async () => this.getOrRefreshToken()
|
|
186
|
+
}));
|
|
187
|
+
const connectionId = normalizeConnectionId(response.connectionId) ?? this.workspaceId;
|
|
188
|
+
this._pendingConnections.set(provider, connectionId);
|
|
189
|
+
return {
|
|
190
|
+
alreadyConnected: false,
|
|
191
|
+
connectLink: response.connectLink,
|
|
192
|
+
sessionToken: response.token,
|
|
193
|
+
expiresAt: response.expiresAt,
|
|
194
|
+
connectionId
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
async waitForConnection(provider, options = {}) {
|
|
198
|
+
assertProvider(provider);
|
|
199
|
+
const connectionId = this.resolveConnectionId(provider, options.connectionId);
|
|
200
|
+
const intervalMs = Math.max(0, Math.floor(options.intervalMs ?? DEFAULT_WAIT_INTERVAL_MS));
|
|
201
|
+
const timeoutMs = Math.max(1, Math.floor(options.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS));
|
|
202
|
+
const startedAt = Date.now();
|
|
203
|
+
let attempt = 0;
|
|
204
|
+
for (;;) {
|
|
205
|
+
throwIfAborted(options.signal, "waitForConnection");
|
|
206
|
+
const elapsedMs = Date.now() - startedAt;
|
|
207
|
+
if (elapsedMs >= timeoutMs) {
|
|
208
|
+
throw new IntegrationConnectionTimeoutError({
|
|
209
|
+
provider,
|
|
210
|
+
connectionId,
|
|
211
|
+
elapsedMs,
|
|
212
|
+
timeoutMs
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
attempt += 1;
|
|
216
|
+
const remainingMs = timeoutMs - elapsedMs;
|
|
217
|
+
let ready;
|
|
218
|
+
try {
|
|
219
|
+
ready = await this.getConnectionStatus(provider, connectionId, {
|
|
220
|
+
signal: options.signal,
|
|
221
|
+
timeoutMs: remainingMs
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
if (error instanceof CloudTimeoutError) {
|
|
226
|
+
throw new IntegrationConnectionTimeoutError({
|
|
227
|
+
provider,
|
|
228
|
+
connectionId,
|
|
229
|
+
elapsedMs: Date.now() - startedAt,
|
|
230
|
+
timeoutMs
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
options.onPoll?.(attempt, ready);
|
|
236
|
+
if (ready) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const sleepMs = Math.min(intervalMs, Math.max(0, timeoutMs - (Date.now() - startedAt)));
|
|
240
|
+
await sleep(sleepMs, options.signal, "waitForConnection");
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async isConnected(provider, connectionId) {
|
|
244
|
+
assertProvider(provider);
|
|
245
|
+
return this.getConnectionStatus(provider, this.resolveConnectionId(provider, connectionId));
|
|
246
|
+
}
|
|
247
|
+
async disconnectIntegration(provider, _connectionId) {
|
|
248
|
+
assertProvider(provider);
|
|
249
|
+
await this._setup.requestJson({
|
|
250
|
+
operation: "disconnectIntegration",
|
|
251
|
+
method: "DELETE",
|
|
252
|
+
path: `api/v1/workspaces/${encodeURIComponent(this.workspaceId)}/integrations/${encodeURIComponent(provider)}/status`,
|
|
253
|
+
tokenProvider: async () => this.getOrRefreshToken()
|
|
254
|
+
});
|
|
255
|
+
this._pendingConnections.delete(provider);
|
|
256
|
+
}
|
|
257
|
+
getToken() {
|
|
258
|
+
return this._token;
|
|
259
|
+
}
|
|
260
|
+
async refreshToken() {
|
|
261
|
+
if (!this._refreshPromise) {
|
|
262
|
+
this._refreshPromise = this.performRefreshToken();
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
await this._refreshPromise;
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
this._refreshPromise = undefined;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
async performRefreshToken() {
|
|
272
|
+
const response = await this._setup.joinWorkspaceResponse(this.workspaceId, this._joinOptions);
|
|
273
|
+
this._token = response.token;
|
|
274
|
+
this._tokenIssuedAt = Date.now();
|
|
275
|
+
}
|
|
276
|
+
async getOrRefreshToken() {
|
|
277
|
+
if (Date.now() - this._tokenIssuedAt >= TOKEN_REFRESH_AGE_MS) {
|
|
278
|
+
await this.refreshToken();
|
|
279
|
+
}
|
|
280
|
+
return this._token;
|
|
281
|
+
}
|
|
282
|
+
resolveConnectionId(provider, connectionId) {
|
|
283
|
+
const resolved = normalizeConnectionId(connectionId) ?? this._pendingConnections.get(provider);
|
|
284
|
+
if (!resolved) {
|
|
285
|
+
throw new MissingConnectionIdError(provider);
|
|
286
|
+
}
|
|
287
|
+
return resolved;
|
|
288
|
+
}
|
|
289
|
+
async getConnectionStatus(provider, connectionId, options = {}) {
|
|
290
|
+
const query = new URLSearchParams({ connectionId });
|
|
291
|
+
const response = validateIntegrationStatusResponse(await this._setup.requestJson({
|
|
292
|
+
operation: "getIntegrationStatus",
|
|
293
|
+
method: "GET",
|
|
294
|
+
path: `api/v1/workspaces/${encodeURIComponent(this.workspaceId)}/integrations/${encodeURIComponent(provider)}/status?${query.toString()}`,
|
|
295
|
+
signal: options.signal,
|
|
296
|
+
timeoutMs: options.timeoutMs,
|
|
297
|
+
tokenProvider: async () => this.getOrRefreshToken()
|
|
298
|
+
}));
|
|
299
|
+
return response.ready;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function assertProvider(provider) {
|
|
303
|
+
if (!WORKSPACE_INTEGRATION_PROVIDERS.includes(provider)) {
|
|
304
|
+
throw new UnknownProviderError(provider);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function normalizeJoinWorkspaceOptions(options = {}) {
|
|
308
|
+
return {
|
|
309
|
+
agentName: normalizeNonEmptyString(options.agentName) ?? DEFAULT_AGENT_NAME,
|
|
310
|
+
scopes: options.scopes && options.scopes.length > 0
|
|
311
|
+
? [...options.scopes]
|
|
312
|
+
: [...DEFAULT_SCOPES],
|
|
313
|
+
permissions: clonePermissions(options.permissions)
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
function normalizeRetryOptions(options) {
|
|
317
|
+
const maxRetries = Math.max(0, Math.floor(options?.maxRetries ?? DEFAULT_RETRY_MAX_RETRIES));
|
|
318
|
+
const baseDelayMs = Math.max(1, Math.floor(options?.baseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS));
|
|
319
|
+
return {
|
|
320
|
+
maxRetries,
|
|
321
|
+
baseDelayMs,
|
|
322
|
+
maxDelayMs: DEFAULT_RETRY_MAX_DELAY_MS
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
function validateCreateWorkspaceResponse(payload) {
|
|
326
|
+
return {
|
|
327
|
+
workspaceId: requireStringField(payload, "workspaceId"),
|
|
328
|
+
relayfileUrl: requireStringField(payload, "relayfileUrl"),
|
|
329
|
+
relaycastApiKey: requireStringField(payload, "relaycastApiKey"),
|
|
330
|
+
createdAt: requireStringField(payload, "createdAt"),
|
|
331
|
+
name: readOptionalStringField(payload, "name")
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
function validateJoinWorkspaceResponse(payload) {
|
|
335
|
+
return {
|
|
336
|
+
workspaceId: requireStringField(payload, "workspaceId"),
|
|
337
|
+
token: requireStringField(payload, "token"),
|
|
338
|
+
relayfileUrl: requireStringField(payload, "relayfileUrl"),
|
|
339
|
+
wsUrl: requireStringField(payload, "wsUrl"),
|
|
340
|
+
relaycastApiKey: requireStringField(payload, "relaycastApiKey")
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
function validateConnectSessionResponse(payload) {
|
|
344
|
+
return {
|
|
345
|
+
token: requireStringField(payload, "token"),
|
|
346
|
+
expiresAt: requireStringField(payload, "expiresAt"),
|
|
347
|
+
connectLink: requireStringField(payload, "connectLink"),
|
|
348
|
+
connectionId: readOptionalStringField(payload, "connectionId")
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
function validateIntegrationStatusResponse(payload) {
|
|
352
|
+
return {
|
|
353
|
+
ready: requireBooleanField(payload, "ready")
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
function requireStringField(payload, field) {
|
|
357
|
+
const value = readField(payload, field);
|
|
358
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
359
|
+
throw new MalformedCloudResponseError(field, payload);
|
|
360
|
+
}
|
|
361
|
+
return value;
|
|
362
|
+
}
|
|
363
|
+
function readOptionalStringField(payload, field) {
|
|
364
|
+
const value = readField(payload, field);
|
|
365
|
+
if (value === undefined) {
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
369
|
+
throw new MalformedCloudResponseError(field, payload);
|
|
370
|
+
}
|
|
371
|
+
return value;
|
|
372
|
+
}
|
|
373
|
+
function requireBooleanField(payload, field) {
|
|
374
|
+
const value = readField(payload, field);
|
|
375
|
+
if (typeof value !== "boolean") {
|
|
376
|
+
throw new MalformedCloudResponseError(field, payload);
|
|
377
|
+
}
|
|
378
|
+
return value;
|
|
379
|
+
}
|
|
380
|
+
function readField(payload, field) {
|
|
381
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
return payload[field];
|
|
385
|
+
}
|
|
386
|
+
function normalizeConnectionId(connectionId) {
|
|
387
|
+
return normalizeNonEmptyString(connectionId);
|
|
388
|
+
}
|
|
389
|
+
function normalizeNonEmptyString(value) {
|
|
390
|
+
const normalized = value?.trim();
|
|
391
|
+
return normalized ? normalized : undefined;
|
|
392
|
+
}
|
|
393
|
+
function clonePermissions(permissions) {
|
|
394
|
+
if (!permissions) {
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
397
|
+
return compactObject({
|
|
398
|
+
readonly: permissions.readonly ? [...permissions.readonly] : undefined,
|
|
399
|
+
ignored: permissions.ignored ? [...permissions.ignored] : undefined
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
function compactObject(value) {
|
|
403
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
404
|
+
}
|
|
405
|
+
function buildCloudUrl(baseUrl, path) {
|
|
406
|
+
const base = new URL(baseUrl);
|
|
407
|
+
if (!base.pathname.endsWith("/")) {
|
|
408
|
+
base.pathname = `${base.pathname}/`;
|
|
409
|
+
}
|
|
410
|
+
return new URL(path.replace(/^\/+/, ""), base).toString();
|
|
411
|
+
}
|
|
412
|
+
async function resolveToken(provider) {
|
|
413
|
+
if (!provider) {
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
416
|
+
return typeof provider === "function" ? provider() : provider;
|
|
417
|
+
}
|
|
418
|
+
async function fetchWithTimeout(url, init, timeoutMs, operation) {
|
|
419
|
+
if (init.signal?.aborted) {
|
|
420
|
+
throw new CloudAbortError(operation);
|
|
421
|
+
}
|
|
422
|
+
let didTimeout = false;
|
|
423
|
+
let didAbort = false;
|
|
424
|
+
const controller = new AbortController();
|
|
425
|
+
const onAbort = () => {
|
|
426
|
+
didAbort = true;
|
|
427
|
+
controller.abort();
|
|
428
|
+
};
|
|
429
|
+
const timer = setTimeout(() => {
|
|
430
|
+
didTimeout = true;
|
|
431
|
+
controller.abort();
|
|
432
|
+
}, timeoutMs);
|
|
433
|
+
init.signal?.addEventListener("abort", onAbort, { once: true });
|
|
434
|
+
try {
|
|
435
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
if (didTimeout) {
|
|
439
|
+
throw new CloudTimeoutError(operation, timeoutMs);
|
|
440
|
+
}
|
|
441
|
+
if (didAbort || init.signal?.aborted) {
|
|
442
|
+
throw new CloudAbortError(operation);
|
|
443
|
+
}
|
|
444
|
+
throw error;
|
|
445
|
+
}
|
|
446
|
+
finally {
|
|
447
|
+
clearTimeout(timer);
|
|
448
|
+
init.signal?.removeEventListener("abort", onAbort);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
async function readResponseBody(response) {
|
|
452
|
+
const text = await response.text();
|
|
453
|
+
if (text === "") {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
457
|
+
if (contentType.includes("application/json")) {
|
|
458
|
+
try {
|
|
459
|
+
return JSON.parse(text);
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
return text;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return text;
|
|
466
|
+
}
|
|
467
|
+
function shouldRetryStatus(status, retries, maxRetries, signal) {
|
|
468
|
+
if (signal?.aborted || retries >= maxRetries) {
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
return status === 429 || (status >= 500 && status <= 599);
|
|
472
|
+
}
|
|
473
|
+
function shouldRetryError(error, retries, maxRetries, signal) {
|
|
474
|
+
if (signal?.aborted || retries >= maxRetries) {
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
return !(error instanceof Error && error.name === "AbortError");
|
|
478
|
+
}
|
|
479
|
+
function computeRetryDelayMs(options, retryAttempt, retryAfterHeader) {
|
|
480
|
+
const retryAfterMs = parseRetryAfterMs(retryAfterHeader);
|
|
481
|
+
if (retryAfterMs !== null) {
|
|
482
|
+
return Math.min(options.maxDelayMs, retryAfterMs);
|
|
483
|
+
}
|
|
484
|
+
const backoff = options.baseDelayMs * Math.pow(2, Math.max(0, retryAttempt - 1));
|
|
485
|
+
return Math.min(options.maxDelayMs, backoff);
|
|
486
|
+
}
|
|
487
|
+
function parseRetryAfterMs(retryAfterHeader) {
|
|
488
|
+
if (!retryAfterHeader) {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
const seconds = Number.parseInt(retryAfterHeader, 10);
|
|
492
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
493
|
+
return seconds * 1000;
|
|
494
|
+
}
|
|
495
|
+
const timestamp = Date.parse(retryAfterHeader);
|
|
496
|
+
if (!Number.isNaN(timestamp)) {
|
|
497
|
+
return Math.max(0, timestamp - Date.now());
|
|
498
|
+
}
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
async function sleep(delayMs, signal, operation) {
|
|
502
|
+
if (delayMs <= 0) {
|
|
503
|
+
throwIfAborted(signal, operation);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
throwIfAborted(signal, operation);
|
|
507
|
+
await new Promise((resolve, reject) => {
|
|
508
|
+
const timer = setTimeout(() => {
|
|
509
|
+
signal?.removeEventListener("abort", onAbort);
|
|
510
|
+
resolve();
|
|
511
|
+
}, delayMs);
|
|
512
|
+
const onAbort = () => {
|
|
513
|
+
clearTimeout(timer);
|
|
514
|
+
signal?.removeEventListener("abort", onAbort);
|
|
515
|
+
reject(new CloudAbortError(operation));
|
|
516
|
+
};
|
|
517
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
function throwIfAborted(signal, operation) {
|
|
521
|
+
if (signal?.aborted) {
|
|
522
|
+
throw new CloudAbortError(operation);
|
|
523
|
+
}
|
|
524
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@relayfile/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "TypeScript SDK for relayfile — real-time filesystem for humans and agents",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
"build": "tsc",
|
|
13
13
|
"typecheck": "tsc --noEmit",
|
|
14
14
|
"test": "vitest run",
|
|
15
|
+
"setup:e2e": "node scripts/setup-e2e.mjs",
|
|
15
16
|
"prepublishOnly": "npm run build"
|
|
16
17
|
},
|
|
17
18
|
"dependencies": {
|
|
18
|
-
"@relayfile/core": "0.
|
|
19
|
+
"@relayfile/core": "0.6.0"
|
|
19
20
|
},
|
|
20
21
|
"devDependencies": {
|
|
21
22
|
"typescript": "^5.7.3",
|