@granular-software/sdk 0.3.3 → 0.4.1

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.mjs CHANGED
@@ -3917,6 +3917,9 @@ if (typeof globalThis !== "undefined" && globalThis.WebSocket) {
3917
3917
  GlobalWebSocket = globalThis.WebSocket;
3918
3918
  }
3919
3919
  var READY_STATE_OPEN = 1;
3920
+ var TOKEN_REFRESH_LEEWAY_MS = 2 * 60 * 1e3;
3921
+ var TOKEN_REFRESH_RETRY_MS = 30 * 1e3;
3922
+ var MAX_TIMER_DELAY_MS = 2147483647;
3920
3923
  var WSClient = class {
3921
3924
  ws = null;
3922
3925
  url;
@@ -3930,6 +3933,7 @@ var WSClient = class {
3930
3933
  doc = Automerge.init();
3931
3934
  syncState = Automerge.initSyncState();
3932
3935
  reconnectTimer = null;
3936
+ tokenRefreshTimer = null;
3933
3937
  isExplicitlyDisconnected = false;
3934
3938
  options;
3935
3939
  constructor(options) {
@@ -3938,12 +3942,113 @@ var WSClient = class {
3938
3942
  this.sessionId = options.sessionId;
3939
3943
  this.token = options.token;
3940
3944
  }
3945
+ clearTokenRefreshTimer() {
3946
+ if (this.tokenRefreshTimer) {
3947
+ clearTimeout(this.tokenRefreshTimer);
3948
+ this.tokenRefreshTimer = null;
3949
+ }
3950
+ }
3951
+ decodeBase64Url(payload) {
3952
+ const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
3953
+ const padded = base64 + "=".repeat((4 - (base64.length % 4 || 4)) % 4);
3954
+ if (typeof atob === "function") {
3955
+ return atob(padded);
3956
+ }
3957
+ const maybeBuffer = globalThis.Buffer;
3958
+ if (maybeBuffer) {
3959
+ return maybeBuffer.from(padded, "base64").toString("utf8");
3960
+ }
3961
+ throw new Error("No base64 decoder available");
3962
+ }
3963
+ getTokenExpiryMs(token) {
3964
+ const parts = token.split(".");
3965
+ if (parts.length < 2) {
3966
+ return null;
3967
+ }
3968
+ try {
3969
+ const payloadRaw = this.decodeBase64Url(parts[1]);
3970
+ const payload = JSON.parse(payloadRaw);
3971
+ if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) {
3972
+ return null;
3973
+ }
3974
+ return payload.exp * 1e3;
3975
+ } catch {
3976
+ return null;
3977
+ }
3978
+ }
3979
+ scheduleTokenRefresh() {
3980
+ this.clearTokenRefreshTimer();
3981
+ if (!this.options.tokenProvider || this.isExplicitlyDisconnected) {
3982
+ return;
3983
+ }
3984
+ const expiresAt = this.getTokenExpiryMs(this.token);
3985
+ if (!expiresAt) {
3986
+ return;
3987
+ }
3988
+ const refreshInMs = Math.max(1e3, expiresAt - Date.now() - TOKEN_REFRESH_LEEWAY_MS);
3989
+ const delay = Math.min(refreshInMs, MAX_TIMER_DELAY_MS);
3990
+ this.tokenRefreshTimer = setTimeout(() => {
3991
+ void this.refreshTokenInBackground();
3992
+ }, delay);
3993
+ }
3994
+ async refreshTokenInBackground() {
3995
+ if (!this.options.tokenProvider || this.isExplicitlyDisconnected) {
3996
+ return;
3997
+ }
3998
+ try {
3999
+ const refreshedToken = await this.options.tokenProvider();
4000
+ if (typeof refreshedToken !== "string" || refreshedToken.length === 0) {
4001
+ throw new Error("Token provider returned no token");
4002
+ }
4003
+ this.token = refreshedToken;
4004
+ this.scheduleTokenRefresh();
4005
+ } catch (error) {
4006
+ if (this.isExplicitlyDisconnected) {
4007
+ return;
4008
+ }
4009
+ console.warn("[Granular] Token refresh failed, retrying soon:", error);
4010
+ this.clearTokenRefreshTimer();
4011
+ this.tokenRefreshTimer = setTimeout(() => {
4012
+ void this.refreshTokenInBackground();
4013
+ }, TOKEN_REFRESH_RETRY_MS);
4014
+ }
4015
+ }
4016
+ async resolveTokenForConnect() {
4017
+ if (!this.options.tokenProvider) {
4018
+ return this.token;
4019
+ }
4020
+ const expiresAt = this.getTokenExpiryMs(this.token);
4021
+ const shouldRefresh = expiresAt !== null && expiresAt - Date.now() <= TOKEN_REFRESH_LEEWAY_MS;
4022
+ if (!shouldRefresh) {
4023
+ return this.token;
4024
+ }
4025
+ try {
4026
+ const refreshedToken = await this.options.tokenProvider();
4027
+ if (typeof refreshedToken !== "string" || refreshedToken.length === 0) {
4028
+ throw new Error("Token provider returned no token");
4029
+ }
4030
+ this.token = refreshedToken;
4031
+ return refreshedToken;
4032
+ } catch (error) {
4033
+ if (expiresAt > Date.now()) {
4034
+ console.warn("[Granular] Token refresh failed, using current token:", error);
4035
+ return this.token;
4036
+ }
4037
+ throw error;
4038
+ }
4039
+ }
3941
4040
  /**
3942
4041
  * Connect to the WebSocket server
3943
4042
  * @returns {Promise<void>} Resolves when connection is open
3944
4043
  */
3945
4044
  async connect() {
4045
+ const token = await this.resolveTokenForConnect();
3946
4046
  this.isExplicitlyDisconnected = false;
4047
+ this.scheduleTokenRefresh();
4048
+ if (this.reconnectTimer) {
4049
+ clearTimeout(this.reconnectTimer);
4050
+ this.reconnectTimer = null;
4051
+ }
3947
4052
  let WebSocketClass = this.options.WebSocketCtor || GlobalWebSocket;
3948
4053
  if (!WebSocketClass) {
3949
4054
  try {
@@ -3959,12 +4064,16 @@ var WSClient = class {
3959
4064
  try {
3960
4065
  const wsUrl = new URL(this.url);
3961
4066
  wsUrl.searchParams.set("sessionId", this.sessionId);
3962
- wsUrl.searchParams.set("token", this.token);
4067
+ wsUrl.searchParams.set("token", token);
3963
4068
  this.ws = new WebSocketClass(wsUrl.toString());
3964
4069
  if (!this.ws) throw new Error("Failed to create WebSocket");
3965
4070
  const socket = this.ws;
3966
4071
  if (typeof socket.on === "function") {
3967
4072
  socket.on("open", () => {
4073
+ if (this.reconnectTimer) {
4074
+ clearTimeout(this.reconnectTimer);
4075
+ this.reconnectTimer = null;
4076
+ }
3968
4077
  this.emit("open", {});
3969
4078
  resolve();
3970
4079
  });
@@ -3982,12 +4091,20 @@ var WSClient = class {
3982
4091
  reject(error);
3983
4092
  }
3984
4093
  });
3985
- socket.on("close", () => {
3986
- this.emit("close", {});
3987
- this.handleDisconnect();
4094
+ socket.on("close", (code, reason) => {
4095
+ this.handleDisconnect({
4096
+ code,
4097
+ reason: this.normalizeReason(reason),
4098
+ // ws does not provide wasClean on Node-style close callback
4099
+ wasClean: code === 1e3
4100
+ });
3988
4101
  });
3989
4102
  } else {
3990
4103
  this.ws.onopen = () => {
4104
+ if (this.reconnectTimer) {
4105
+ clearTimeout(this.reconnectTimer);
4106
+ this.reconnectTimer = null;
4107
+ }
3991
4108
  this.emit("open", {});
3992
4109
  resolve();
3993
4110
  };
@@ -4008,9 +4125,12 @@ var WSClient = class {
4008
4125
  reject(error);
4009
4126
  }
4010
4127
  };
4011
- this.ws.onclose = () => {
4012
- this.emit("close", {});
4013
- this.handleDisconnect();
4128
+ this.ws.onclose = (event) => {
4129
+ this.handleDisconnect({
4130
+ code: event.code,
4131
+ reason: event.reason,
4132
+ wasClean: event.wasClean
4133
+ });
4014
4134
  };
4015
4135
  }
4016
4136
  } catch (error) {
@@ -4018,13 +4138,80 @@ var WSClient = class {
4018
4138
  }
4019
4139
  });
4020
4140
  }
4021
- handleDisconnect() {
4141
+ normalizeReason(reason) {
4142
+ if (reason === null || reason === void 0) return void 0;
4143
+ if (typeof reason === "string") return reason;
4144
+ if (typeof reason === "object" && reason && "toString" in reason) {
4145
+ try {
4146
+ const text = String(reason.toString());
4147
+ return text || void 0;
4148
+ } catch {
4149
+ return void 0;
4150
+ }
4151
+ }
4152
+ return void 0;
4153
+ }
4154
+ rejectPending(error) {
4155
+ this.messageQueue.forEach((pending) => pending.reject(error));
4156
+ this.messageQueue = [];
4157
+ }
4158
+ buildDisconnectError(info) {
4159
+ const details = [
4160
+ info.code !== void 0 ? `code=${info.code}` : void 0,
4161
+ info.reason ? `reason=${info.reason}` : void 0
4162
+ ].filter(Boolean).join(", ");
4163
+ const suffix = details ? ` (${details})` : "";
4164
+ return new Error(`WebSocket disconnected${suffix}`);
4165
+ }
4166
+ handleDisconnect(close = {}) {
4167
+ const reconnectDelayMs = 3e3;
4168
+ const unexpected = !this.isExplicitlyDisconnected;
4169
+ const info = {
4170
+ code: close.code,
4171
+ reason: close.reason,
4172
+ wasClean: close.wasClean,
4173
+ unexpected,
4174
+ timestamp: Date.now(),
4175
+ reconnectScheduled: false
4176
+ };
4022
4177
  this.ws = null;
4023
- if (!this.isExplicitlyDisconnected) {
4178
+ this.emit("close", info);
4179
+ if (this.reconnectTimer) {
4180
+ clearTimeout(this.reconnectTimer);
4181
+ this.reconnectTimer = null;
4182
+ }
4183
+ if (unexpected) {
4184
+ const disconnectError = this.buildDisconnectError(info);
4185
+ this.rejectPending(disconnectError);
4186
+ this.emit("disconnect", info);
4187
+ info.reconnectScheduled = true;
4188
+ info.reconnectDelayMs = reconnectDelayMs;
4189
+ if (this.options.onUnexpectedClose) {
4190
+ try {
4191
+ this.options.onUnexpectedClose(info);
4192
+ } catch (callbackError) {
4193
+ console.error("[Granular] onUnexpectedClose callback failed:", callbackError);
4194
+ }
4195
+ }
4024
4196
  this.reconnectTimer = setTimeout(() => {
4025
4197
  console.log("[Granular] Attempting reconnect...");
4026
- this.connect().catch((e) => console.error("[Granular] Reconnect failed:", e));
4027
- }, 3e3);
4198
+ this.connect().catch((error) => {
4199
+ console.error("[Granular] Reconnect failed:", error);
4200
+ const reconnectInfo = {
4201
+ error: error instanceof Error ? error.message : String(error),
4202
+ sessionId: this.sessionId,
4203
+ timestamp: Date.now()
4204
+ };
4205
+ this.emit("reconnect_error", reconnectInfo);
4206
+ if (this.options.onReconnectError) {
4207
+ try {
4208
+ this.options.onReconnectError(reconnectInfo);
4209
+ } catch (callbackError) {
4210
+ console.error("[Granular] onReconnectError callback failed:", callbackError);
4211
+ }
4212
+ }
4213
+ });
4214
+ }, reconnectDelayMs);
4028
4215
  }
4029
4216
  }
4030
4217
  handleMessage(message) {
@@ -4082,10 +4269,10 @@ var WSClient = class {
4082
4269
  const snapshotMessage = message;
4083
4270
  try {
4084
4271
  const bytes = new Uint8Array(snapshotMessage.data);
4085
- console.log("[Granular DEBUG] Loading snapshot bytes:", bytes.length);
4272
+ console.log("[Granular DEBUG] Loading Automerge session snapshot bytes:", bytes.length);
4086
4273
  this.doc = Automerge.load(bytes);
4087
4274
  this.emit("sync", this.doc);
4088
- console.log("[Granular DEBUG] Snapshot loaded. Doc:", JSON.stringify(Automerge.toJS(this.doc)));
4275
+ console.log("[Granular DEBUG] Automerge session snapshot loaded. Doc:", JSON.stringify(Automerge.toJS(this.doc)));
4089
4276
  } catch (e) {
4090
4277
  console.warn("[Granular] Failed to load snapshot message", e);
4091
4278
  }
@@ -4244,13 +4431,16 @@ var WSClient = class {
4244
4431
  */
4245
4432
  disconnect() {
4246
4433
  this.isExplicitlyDisconnected = true;
4247
- if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
4434
+ if (this.reconnectTimer) {
4435
+ clearTimeout(this.reconnectTimer);
4436
+ this.reconnectTimer = null;
4437
+ }
4438
+ this.clearTokenRefreshTimer();
4248
4439
  if (this.ws) {
4249
- this.ws.close();
4440
+ this.ws.close(1e3, "Client disconnect");
4250
4441
  this.ws = null;
4251
4442
  }
4252
- this.messageQueue.forEach((q) => q.reject(new Error("Client explicitly disconnected")));
4253
- this.messageQueue = [];
4443
+ this.rejectPending(new Error("Client explicitly disconnected"));
4254
4444
  this.rpcHandlers.clear();
4255
4445
  this.emit("disconnect", {});
4256
4446
  }
@@ -4732,6 +4922,13 @@ import { ${allImports} } from "./sandbox-tools";
4732
4922
  * Close the session and disconnect from the sandbox
4733
4923
  */
4734
4924
  async disconnect() {
4925
+ try {
4926
+ await this.client.call("client.goodbye", {
4927
+ clientId: this.clientId,
4928
+ timestamp: Date.now()
4929
+ });
4930
+ } catch (error) {
4931
+ }
4735
4932
  this.client.disconnect();
4736
4933
  }
4737
4934
  // --- Event Handling ---
@@ -4795,7 +4992,7 @@ import { ${allImports} } from "./sandbox-tools";
4795
4992
  this.checkForToolChanges();
4796
4993
  });
4797
4994
  this.client.on("prompt", (prompt) => this.emit("prompt", prompt));
4798
- this.client.on("disconnect", () => this.emit("disconnect", {}));
4995
+ this.client.on("disconnect", (payload) => this.emit("disconnect", payload || {}));
4799
4996
  this.client.on("job.status", (data) => {
4800
4997
  this.emit("job:status", data);
4801
4998
  });
@@ -4942,6 +5139,50 @@ var JobImplementation = class {
4942
5139
  }
4943
5140
  };
4944
5141
 
5142
+ // src/endpoints.ts
5143
+ var LOCAL_API_URL = "ws://localhost:8787/granular";
5144
+ var PRODUCTION_API_URL = "wss://api.granular.dev/v2/ws";
5145
+ function readEnv(name) {
5146
+ if (typeof process === "undefined" || !process.env) return void 0;
5147
+ return process.env[name];
5148
+ }
5149
+ function normalizeMode(value) {
5150
+ if (!value) return void 0;
5151
+ const normalized = value.trim().toLowerCase();
5152
+ if (normalized === "local") return "local";
5153
+ if (normalized === "prod" || normalized === "production") return "production";
5154
+ if (normalized === "auto") return "auto";
5155
+ return void 0;
5156
+ }
5157
+ function isTruthy(value) {
5158
+ if (!value) return false;
5159
+ const normalized = value.trim().toLowerCase();
5160
+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
5161
+ }
5162
+ function resolveEndpointMode(explicitMode) {
5163
+ const explicit = normalizeMode(explicitMode);
5164
+ if (explicit === "local" || explicit === "production") {
5165
+ return explicit;
5166
+ }
5167
+ const envMode = normalizeMode(readEnv("GRANULAR_ENDPOINT_MODE") || readEnv("GRANULAR_ENV"));
5168
+ if (envMode === "local" || envMode === "production") {
5169
+ return envMode;
5170
+ }
5171
+ if (isTruthy(readEnv("GRANULAR_USE_LOCAL_ENDPOINTS")) || isTruthy(readEnv("GRANULAR_LOCAL"))) {
5172
+ return "local";
5173
+ }
5174
+ if (isTruthy(readEnv("GRANULAR_USE_PRODUCTION_ENDPOINTS")) || isTruthy(readEnv("GRANULAR_PROD"))) {
5175
+ return "production";
5176
+ }
5177
+ return readEnv("NODE_ENV") === "development" ? "local" : "production";
5178
+ }
5179
+ function resolveApiUrl(explicitApiUrl, mode) {
5180
+ if (explicitApiUrl) {
5181
+ return explicitApiUrl;
5182
+ }
5183
+ return resolveEndpointMode(mode) === "local" ? LOCAL_API_URL : PRODUCTION_API_URL;
5184
+ }
5185
+
4945
5186
  // src/client.ts
4946
5187
  var STANDARD_MODULES_OPERATIONS = [
4947
5188
  { create: "entity", has: { id: { value: "auto-generated" }, createdAt: { value: void 0 } } },
@@ -4986,6 +5227,60 @@ var Environment = class _Environment extends Session {
4986
5227
  get apiEndpoint() {
4987
5228
  return this._apiEndpoint;
4988
5229
  }
5230
+ getRuntimeBaseUrl() {
5231
+ try {
5232
+ const endpoint = new URL(this._apiEndpoint);
5233
+ const graphqlSuffix = "/orchestrator/graphql";
5234
+ if (endpoint.pathname.endsWith(graphqlSuffix)) {
5235
+ endpoint.pathname = endpoint.pathname.slice(0, -graphqlSuffix.length);
5236
+ } else if (endpoint.pathname.endsWith("/graphql")) {
5237
+ endpoint.pathname = endpoint.pathname.slice(0, -"/graphql".length);
5238
+ }
5239
+ endpoint.search = "";
5240
+ endpoint.hash = "";
5241
+ return endpoint.toString().replace(/\/$/, "");
5242
+ } catch {
5243
+ return this._apiEndpoint.replace(/\/orchestrator\/graphql$/, "").replace(/\/$/, "");
5244
+ }
5245
+ }
5246
+ /**
5247
+ * Close the session and disconnect from the sandbox.
5248
+ *
5249
+ * Sends `client.goodbye` over WebSocket first, then issues an HTTP fallback
5250
+ * to the runtime goodbye endpoint if no definitive WS-side runtime notify
5251
+ * acknowledgement was observed.
5252
+ */
5253
+ async disconnect() {
5254
+ let wsNotifiedRuntime = false;
5255
+ try {
5256
+ const goodbye = await this.rpc("client.goodbye", {
5257
+ timestamp: Date.now()
5258
+ });
5259
+ wsNotifiedRuntime = Boolean(goodbye?.ok && goodbye?.via);
5260
+ } catch {
5261
+ wsNotifiedRuntime = false;
5262
+ }
5263
+ if (!wsNotifiedRuntime) {
5264
+ try {
5265
+ const runtimeBase = this.getRuntimeBaseUrl();
5266
+ await fetch(
5267
+ `${runtimeBase}/orchestrator/runtime/environments/${this.environmentId}/session-goodbye`,
5268
+ {
5269
+ method: "POST",
5270
+ headers: {
5271
+ "Content-Type": "application/json",
5272
+ "Authorization": `Bearer ${this._apiKey}`
5273
+ },
5274
+ body: JSON.stringify({
5275
+ reason: "sdk_disconnect_http_fallback"
5276
+ })
5277
+ }
5278
+ );
5279
+ } catch {
5280
+ }
5281
+ }
5282
+ this.client.disconnect();
5283
+ }
4989
5284
  // ==================== GRAPH CONTAINER READINESS ====================
4990
5285
  /** The last known graph container status, updated by checkReadiness() or on heartbeat */
4991
5286
  graphContainerStatus = null;
@@ -5721,7 +6016,10 @@ var Granular = class {
5721
6016
  apiKey;
5722
6017
  apiUrl;
5723
6018
  httpUrl;
6019
+ tokenProvider;
5724
6020
  WebSocketCtor;
6021
+ onUnexpectedClose;
6022
+ onReconnectError;
5725
6023
  /** Sandbox-level effect registry: sandboxId → (toolName → ToolWithHandler) */
5726
6024
  sandboxEffects = /* @__PURE__ */ new Map();
5727
6025
  /** Active environments tracker: sandboxId → Environment[] */
@@ -5736,9 +6034,12 @@ var Granular = class {
5736
6034
  throw new Error("Granular client requires either apiKey or token. Set GRANULAR_API_KEY or GRANULAR_TOKEN, or pass one in options.");
5737
6035
  }
5738
6036
  this.apiKey = auth;
5739
- this.apiUrl = options.apiUrl || "wss://api.granular.dev/v2/ws";
6037
+ this.apiUrl = resolveApiUrl(options.apiUrl, options.endpointMode);
6038
+ this.tokenProvider = options.tokenProvider;
5740
6039
  this.WebSocketCtor = options.WebSocketCtor;
5741
- this.httpUrl = this.apiUrl.replace("wss://", "https://").replace("/ws", "");
6040
+ this.onUnexpectedClose = options.onUnexpectedClose;
6041
+ this.onReconnectError = options.onReconnectError;
6042
+ this.httpUrl = this.apiUrl.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://").replace(/\/ws$/, "");
5742
6043
  }
5743
6044
  /**
5744
6045
  * Records/upserts a user and prepares them for sandbox connections
@@ -5822,7 +6123,10 @@ var Granular = class {
5822
6123
  url: this.apiUrl,
5823
6124
  sessionId: envData.environmentId,
5824
6125
  token: this.apiKey,
5825
- WebSocketCtor: this.WebSocketCtor
6126
+ tokenProvider: this.tokenProvider,
6127
+ WebSocketCtor: this.WebSocketCtor,
6128
+ onUnexpectedClose: this.onUnexpectedClose,
6129
+ onReconnectError: this.onReconnectError
5826
6130
  });
5827
6131
  await client.connect();
5828
6132
  const graphqlEndpoint = `${this.httpUrl}/orchestrator/graphql`;