@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.js CHANGED
@@ -3939,6 +3939,9 @@ if (typeof globalThis !== "undefined" && globalThis.WebSocket) {
3939
3939
  GlobalWebSocket = globalThis.WebSocket;
3940
3940
  }
3941
3941
  var READY_STATE_OPEN = 1;
3942
+ var TOKEN_REFRESH_LEEWAY_MS = 2 * 60 * 1e3;
3943
+ var TOKEN_REFRESH_RETRY_MS = 30 * 1e3;
3944
+ var MAX_TIMER_DELAY_MS = 2147483647;
3942
3945
  var WSClient = class {
3943
3946
  ws = null;
3944
3947
  url;
@@ -3952,6 +3955,7 @@ var WSClient = class {
3952
3955
  doc = Automerge__namespace.init();
3953
3956
  syncState = Automerge__namespace.initSyncState();
3954
3957
  reconnectTimer = null;
3958
+ tokenRefreshTimer = null;
3955
3959
  isExplicitlyDisconnected = false;
3956
3960
  options;
3957
3961
  constructor(options) {
@@ -3960,12 +3964,113 @@ var WSClient = class {
3960
3964
  this.sessionId = options.sessionId;
3961
3965
  this.token = options.token;
3962
3966
  }
3967
+ clearTokenRefreshTimer() {
3968
+ if (this.tokenRefreshTimer) {
3969
+ clearTimeout(this.tokenRefreshTimer);
3970
+ this.tokenRefreshTimer = null;
3971
+ }
3972
+ }
3973
+ decodeBase64Url(payload) {
3974
+ const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
3975
+ const padded = base64 + "=".repeat((4 - (base64.length % 4 || 4)) % 4);
3976
+ if (typeof atob === "function") {
3977
+ return atob(padded);
3978
+ }
3979
+ const maybeBuffer = globalThis.Buffer;
3980
+ if (maybeBuffer) {
3981
+ return maybeBuffer.from(padded, "base64").toString("utf8");
3982
+ }
3983
+ throw new Error("No base64 decoder available");
3984
+ }
3985
+ getTokenExpiryMs(token) {
3986
+ const parts = token.split(".");
3987
+ if (parts.length < 2) {
3988
+ return null;
3989
+ }
3990
+ try {
3991
+ const payloadRaw = this.decodeBase64Url(parts[1]);
3992
+ const payload = JSON.parse(payloadRaw);
3993
+ if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) {
3994
+ return null;
3995
+ }
3996
+ return payload.exp * 1e3;
3997
+ } catch {
3998
+ return null;
3999
+ }
4000
+ }
4001
+ scheduleTokenRefresh() {
4002
+ this.clearTokenRefreshTimer();
4003
+ if (!this.options.tokenProvider || this.isExplicitlyDisconnected) {
4004
+ return;
4005
+ }
4006
+ const expiresAt = this.getTokenExpiryMs(this.token);
4007
+ if (!expiresAt) {
4008
+ return;
4009
+ }
4010
+ const refreshInMs = Math.max(1e3, expiresAt - Date.now() - TOKEN_REFRESH_LEEWAY_MS);
4011
+ const delay = Math.min(refreshInMs, MAX_TIMER_DELAY_MS);
4012
+ this.tokenRefreshTimer = setTimeout(() => {
4013
+ void this.refreshTokenInBackground();
4014
+ }, delay);
4015
+ }
4016
+ async refreshTokenInBackground() {
4017
+ if (!this.options.tokenProvider || this.isExplicitlyDisconnected) {
4018
+ return;
4019
+ }
4020
+ try {
4021
+ const refreshedToken = await this.options.tokenProvider();
4022
+ if (typeof refreshedToken !== "string" || refreshedToken.length === 0) {
4023
+ throw new Error("Token provider returned no token");
4024
+ }
4025
+ this.token = refreshedToken;
4026
+ this.scheduleTokenRefresh();
4027
+ } catch (error) {
4028
+ if (this.isExplicitlyDisconnected) {
4029
+ return;
4030
+ }
4031
+ console.warn("[Granular] Token refresh failed, retrying soon:", error);
4032
+ this.clearTokenRefreshTimer();
4033
+ this.tokenRefreshTimer = setTimeout(() => {
4034
+ void this.refreshTokenInBackground();
4035
+ }, TOKEN_REFRESH_RETRY_MS);
4036
+ }
4037
+ }
4038
+ async resolveTokenForConnect() {
4039
+ if (!this.options.tokenProvider) {
4040
+ return this.token;
4041
+ }
4042
+ const expiresAt = this.getTokenExpiryMs(this.token);
4043
+ const shouldRefresh = expiresAt !== null && expiresAt - Date.now() <= TOKEN_REFRESH_LEEWAY_MS;
4044
+ if (!shouldRefresh) {
4045
+ return this.token;
4046
+ }
4047
+ try {
4048
+ const refreshedToken = await this.options.tokenProvider();
4049
+ if (typeof refreshedToken !== "string" || refreshedToken.length === 0) {
4050
+ throw new Error("Token provider returned no token");
4051
+ }
4052
+ this.token = refreshedToken;
4053
+ return refreshedToken;
4054
+ } catch (error) {
4055
+ if (expiresAt > Date.now()) {
4056
+ console.warn("[Granular] Token refresh failed, using current token:", error);
4057
+ return this.token;
4058
+ }
4059
+ throw error;
4060
+ }
4061
+ }
3963
4062
  /**
3964
4063
  * Connect to the WebSocket server
3965
4064
  * @returns {Promise<void>} Resolves when connection is open
3966
4065
  */
3967
4066
  async connect() {
4067
+ const token = await this.resolveTokenForConnect();
3968
4068
  this.isExplicitlyDisconnected = false;
4069
+ this.scheduleTokenRefresh();
4070
+ if (this.reconnectTimer) {
4071
+ clearTimeout(this.reconnectTimer);
4072
+ this.reconnectTimer = null;
4073
+ }
3969
4074
  let WebSocketClass = this.options.WebSocketCtor || GlobalWebSocket;
3970
4075
  if (!WebSocketClass) {
3971
4076
  try {
@@ -3981,12 +4086,16 @@ var WSClient = class {
3981
4086
  try {
3982
4087
  const wsUrl = new URL(this.url);
3983
4088
  wsUrl.searchParams.set("sessionId", this.sessionId);
3984
- wsUrl.searchParams.set("token", this.token);
4089
+ wsUrl.searchParams.set("token", token);
3985
4090
  this.ws = new WebSocketClass(wsUrl.toString());
3986
4091
  if (!this.ws) throw new Error("Failed to create WebSocket");
3987
4092
  const socket = this.ws;
3988
4093
  if (typeof socket.on === "function") {
3989
4094
  socket.on("open", () => {
4095
+ if (this.reconnectTimer) {
4096
+ clearTimeout(this.reconnectTimer);
4097
+ this.reconnectTimer = null;
4098
+ }
3990
4099
  this.emit("open", {});
3991
4100
  resolve();
3992
4101
  });
@@ -4004,12 +4113,20 @@ var WSClient = class {
4004
4113
  reject(error);
4005
4114
  }
4006
4115
  });
4007
- socket.on("close", () => {
4008
- this.emit("close", {});
4009
- this.handleDisconnect();
4116
+ socket.on("close", (code, reason) => {
4117
+ this.handleDisconnect({
4118
+ code,
4119
+ reason: this.normalizeReason(reason),
4120
+ // ws does not provide wasClean on Node-style close callback
4121
+ wasClean: code === 1e3
4122
+ });
4010
4123
  });
4011
4124
  } else {
4012
4125
  this.ws.onopen = () => {
4126
+ if (this.reconnectTimer) {
4127
+ clearTimeout(this.reconnectTimer);
4128
+ this.reconnectTimer = null;
4129
+ }
4013
4130
  this.emit("open", {});
4014
4131
  resolve();
4015
4132
  };
@@ -4030,9 +4147,12 @@ var WSClient = class {
4030
4147
  reject(error);
4031
4148
  }
4032
4149
  };
4033
- this.ws.onclose = () => {
4034
- this.emit("close", {});
4035
- this.handleDisconnect();
4150
+ this.ws.onclose = (event) => {
4151
+ this.handleDisconnect({
4152
+ code: event.code,
4153
+ reason: event.reason,
4154
+ wasClean: event.wasClean
4155
+ });
4036
4156
  };
4037
4157
  }
4038
4158
  } catch (error) {
@@ -4040,13 +4160,80 @@ var WSClient = class {
4040
4160
  }
4041
4161
  });
4042
4162
  }
4043
- handleDisconnect() {
4163
+ normalizeReason(reason) {
4164
+ if (reason === null || reason === void 0) return void 0;
4165
+ if (typeof reason === "string") return reason;
4166
+ if (typeof reason === "object" && reason && "toString" in reason) {
4167
+ try {
4168
+ const text = String(reason.toString());
4169
+ return text || void 0;
4170
+ } catch {
4171
+ return void 0;
4172
+ }
4173
+ }
4174
+ return void 0;
4175
+ }
4176
+ rejectPending(error) {
4177
+ this.messageQueue.forEach((pending) => pending.reject(error));
4178
+ this.messageQueue = [];
4179
+ }
4180
+ buildDisconnectError(info) {
4181
+ const details = [
4182
+ info.code !== void 0 ? `code=${info.code}` : void 0,
4183
+ info.reason ? `reason=${info.reason}` : void 0
4184
+ ].filter(Boolean).join(", ");
4185
+ const suffix = details ? ` (${details})` : "";
4186
+ return new Error(`WebSocket disconnected${suffix}`);
4187
+ }
4188
+ handleDisconnect(close = {}) {
4189
+ const reconnectDelayMs = 3e3;
4190
+ const unexpected = !this.isExplicitlyDisconnected;
4191
+ const info = {
4192
+ code: close.code,
4193
+ reason: close.reason,
4194
+ wasClean: close.wasClean,
4195
+ unexpected,
4196
+ timestamp: Date.now(),
4197
+ reconnectScheduled: false
4198
+ };
4044
4199
  this.ws = null;
4045
- if (!this.isExplicitlyDisconnected) {
4200
+ this.emit("close", info);
4201
+ if (this.reconnectTimer) {
4202
+ clearTimeout(this.reconnectTimer);
4203
+ this.reconnectTimer = null;
4204
+ }
4205
+ if (unexpected) {
4206
+ const disconnectError = this.buildDisconnectError(info);
4207
+ this.rejectPending(disconnectError);
4208
+ this.emit("disconnect", info);
4209
+ info.reconnectScheduled = true;
4210
+ info.reconnectDelayMs = reconnectDelayMs;
4211
+ if (this.options.onUnexpectedClose) {
4212
+ try {
4213
+ this.options.onUnexpectedClose(info);
4214
+ } catch (callbackError) {
4215
+ console.error("[Granular] onUnexpectedClose callback failed:", callbackError);
4216
+ }
4217
+ }
4046
4218
  this.reconnectTimer = setTimeout(() => {
4047
4219
  console.log("[Granular] Attempting reconnect...");
4048
- this.connect().catch((e) => console.error("[Granular] Reconnect failed:", e));
4049
- }, 3e3);
4220
+ this.connect().catch((error) => {
4221
+ console.error("[Granular] Reconnect failed:", error);
4222
+ const reconnectInfo = {
4223
+ error: error instanceof Error ? error.message : String(error),
4224
+ sessionId: this.sessionId,
4225
+ timestamp: Date.now()
4226
+ };
4227
+ this.emit("reconnect_error", reconnectInfo);
4228
+ if (this.options.onReconnectError) {
4229
+ try {
4230
+ this.options.onReconnectError(reconnectInfo);
4231
+ } catch (callbackError) {
4232
+ console.error("[Granular] onReconnectError callback failed:", callbackError);
4233
+ }
4234
+ }
4235
+ });
4236
+ }, reconnectDelayMs);
4050
4237
  }
4051
4238
  }
4052
4239
  handleMessage(message) {
@@ -4104,10 +4291,10 @@ var WSClient = class {
4104
4291
  const snapshotMessage = message;
4105
4292
  try {
4106
4293
  const bytes = new Uint8Array(snapshotMessage.data);
4107
- console.log("[Granular DEBUG] Loading snapshot bytes:", bytes.length);
4294
+ console.log("[Granular DEBUG] Loading Automerge session snapshot bytes:", bytes.length);
4108
4295
  this.doc = Automerge__namespace.load(bytes);
4109
4296
  this.emit("sync", this.doc);
4110
- console.log("[Granular DEBUG] Snapshot loaded. Doc:", JSON.stringify(Automerge__namespace.toJS(this.doc)));
4297
+ console.log("[Granular DEBUG] Automerge session snapshot loaded. Doc:", JSON.stringify(Automerge__namespace.toJS(this.doc)));
4111
4298
  } catch (e) {
4112
4299
  console.warn("[Granular] Failed to load snapshot message", e);
4113
4300
  }
@@ -4266,13 +4453,16 @@ var WSClient = class {
4266
4453
  */
4267
4454
  disconnect() {
4268
4455
  this.isExplicitlyDisconnected = true;
4269
- if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
4456
+ if (this.reconnectTimer) {
4457
+ clearTimeout(this.reconnectTimer);
4458
+ this.reconnectTimer = null;
4459
+ }
4460
+ this.clearTokenRefreshTimer();
4270
4461
  if (this.ws) {
4271
- this.ws.close();
4462
+ this.ws.close(1e3, "Client disconnect");
4272
4463
  this.ws = null;
4273
4464
  }
4274
- this.messageQueue.forEach((q) => q.reject(new Error("Client explicitly disconnected")));
4275
- this.messageQueue = [];
4465
+ this.rejectPending(new Error("Client explicitly disconnected"));
4276
4466
  this.rpcHandlers.clear();
4277
4467
  this.emit("disconnect", {});
4278
4468
  }
@@ -4754,6 +4944,13 @@ import { ${allImports} } from "./sandbox-tools";
4754
4944
  * Close the session and disconnect from the sandbox
4755
4945
  */
4756
4946
  async disconnect() {
4947
+ try {
4948
+ await this.client.call("client.goodbye", {
4949
+ clientId: this.clientId,
4950
+ timestamp: Date.now()
4951
+ });
4952
+ } catch (error) {
4953
+ }
4757
4954
  this.client.disconnect();
4758
4955
  }
4759
4956
  // --- Event Handling ---
@@ -4817,7 +5014,7 @@ import { ${allImports} } from "./sandbox-tools";
4817
5014
  this.checkForToolChanges();
4818
5015
  });
4819
5016
  this.client.on("prompt", (prompt) => this.emit("prompt", prompt));
4820
- this.client.on("disconnect", () => this.emit("disconnect", {}));
5017
+ this.client.on("disconnect", (payload) => this.emit("disconnect", payload || {}));
4821
5018
  this.client.on("job.status", (data) => {
4822
5019
  this.emit("job:status", data);
4823
5020
  });
@@ -4964,6 +5161,50 @@ var JobImplementation = class {
4964
5161
  }
4965
5162
  };
4966
5163
 
5164
+ // src/endpoints.ts
5165
+ var LOCAL_API_URL = "ws://localhost:8787/granular";
5166
+ var PRODUCTION_API_URL = "wss://api.granular.dev/v2/ws";
5167
+ function readEnv(name) {
5168
+ if (typeof process === "undefined" || !process.env) return void 0;
5169
+ return process.env[name];
5170
+ }
5171
+ function normalizeMode(value) {
5172
+ if (!value) return void 0;
5173
+ const normalized = value.trim().toLowerCase();
5174
+ if (normalized === "local") return "local";
5175
+ if (normalized === "prod" || normalized === "production") return "production";
5176
+ if (normalized === "auto") return "auto";
5177
+ return void 0;
5178
+ }
5179
+ function isTruthy(value) {
5180
+ if (!value) return false;
5181
+ const normalized = value.trim().toLowerCase();
5182
+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
5183
+ }
5184
+ function resolveEndpointMode(explicitMode) {
5185
+ const explicit = normalizeMode(explicitMode);
5186
+ if (explicit === "local" || explicit === "production") {
5187
+ return explicit;
5188
+ }
5189
+ const envMode = normalizeMode(readEnv("GRANULAR_ENDPOINT_MODE") || readEnv("GRANULAR_ENV"));
5190
+ if (envMode === "local" || envMode === "production") {
5191
+ return envMode;
5192
+ }
5193
+ if (isTruthy(readEnv("GRANULAR_USE_LOCAL_ENDPOINTS")) || isTruthy(readEnv("GRANULAR_LOCAL"))) {
5194
+ return "local";
5195
+ }
5196
+ if (isTruthy(readEnv("GRANULAR_USE_PRODUCTION_ENDPOINTS")) || isTruthy(readEnv("GRANULAR_PROD"))) {
5197
+ return "production";
5198
+ }
5199
+ return readEnv("NODE_ENV") === "development" ? "local" : "production";
5200
+ }
5201
+ function resolveApiUrl(explicitApiUrl, mode) {
5202
+ if (explicitApiUrl) {
5203
+ return explicitApiUrl;
5204
+ }
5205
+ return resolveEndpointMode(mode) === "local" ? LOCAL_API_URL : PRODUCTION_API_URL;
5206
+ }
5207
+
4967
5208
  // src/client.ts
4968
5209
  var STANDARD_MODULES_OPERATIONS = [
4969
5210
  { create: "entity", has: { id: { value: "auto-generated" }, createdAt: { value: void 0 } } },
@@ -5008,6 +5249,60 @@ var Environment = class _Environment extends Session {
5008
5249
  get apiEndpoint() {
5009
5250
  return this._apiEndpoint;
5010
5251
  }
5252
+ getRuntimeBaseUrl() {
5253
+ try {
5254
+ const endpoint = new URL(this._apiEndpoint);
5255
+ const graphqlSuffix = "/orchestrator/graphql";
5256
+ if (endpoint.pathname.endsWith(graphqlSuffix)) {
5257
+ endpoint.pathname = endpoint.pathname.slice(0, -graphqlSuffix.length);
5258
+ } else if (endpoint.pathname.endsWith("/graphql")) {
5259
+ endpoint.pathname = endpoint.pathname.slice(0, -"/graphql".length);
5260
+ }
5261
+ endpoint.search = "";
5262
+ endpoint.hash = "";
5263
+ return endpoint.toString().replace(/\/$/, "");
5264
+ } catch {
5265
+ return this._apiEndpoint.replace(/\/orchestrator\/graphql$/, "").replace(/\/$/, "");
5266
+ }
5267
+ }
5268
+ /**
5269
+ * Close the session and disconnect from the sandbox.
5270
+ *
5271
+ * Sends `client.goodbye` over WebSocket first, then issues an HTTP fallback
5272
+ * to the runtime goodbye endpoint if no definitive WS-side runtime notify
5273
+ * acknowledgement was observed.
5274
+ */
5275
+ async disconnect() {
5276
+ let wsNotifiedRuntime = false;
5277
+ try {
5278
+ const goodbye = await this.rpc("client.goodbye", {
5279
+ timestamp: Date.now()
5280
+ });
5281
+ wsNotifiedRuntime = Boolean(goodbye?.ok && goodbye?.via);
5282
+ } catch {
5283
+ wsNotifiedRuntime = false;
5284
+ }
5285
+ if (!wsNotifiedRuntime) {
5286
+ try {
5287
+ const runtimeBase = this.getRuntimeBaseUrl();
5288
+ await fetch(
5289
+ `${runtimeBase}/orchestrator/runtime/environments/${this.environmentId}/session-goodbye`,
5290
+ {
5291
+ method: "POST",
5292
+ headers: {
5293
+ "Content-Type": "application/json",
5294
+ "Authorization": `Bearer ${this._apiKey}`
5295
+ },
5296
+ body: JSON.stringify({
5297
+ reason: "sdk_disconnect_http_fallback"
5298
+ })
5299
+ }
5300
+ );
5301
+ } catch {
5302
+ }
5303
+ }
5304
+ this.client.disconnect();
5305
+ }
5011
5306
  // ==================== GRAPH CONTAINER READINESS ====================
5012
5307
  /** The last known graph container status, updated by checkReadiness() or on heartbeat */
5013
5308
  graphContainerStatus = null;
@@ -5743,7 +6038,10 @@ var Granular = class {
5743
6038
  apiKey;
5744
6039
  apiUrl;
5745
6040
  httpUrl;
6041
+ tokenProvider;
5746
6042
  WebSocketCtor;
6043
+ onUnexpectedClose;
6044
+ onReconnectError;
5747
6045
  /** Sandbox-level effect registry: sandboxId → (toolName → ToolWithHandler) */
5748
6046
  sandboxEffects = /* @__PURE__ */ new Map();
5749
6047
  /** Active environments tracker: sandboxId → Environment[] */
@@ -5758,9 +6056,12 @@ var Granular = class {
5758
6056
  throw new Error("Granular client requires either apiKey or token. Set GRANULAR_API_KEY or GRANULAR_TOKEN, or pass one in options.");
5759
6057
  }
5760
6058
  this.apiKey = auth;
5761
- this.apiUrl = options.apiUrl || "wss://api.granular.dev/v2/ws";
6059
+ this.apiUrl = resolveApiUrl(options.apiUrl, options.endpointMode);
6060
+ this.tokenProvider = options.tokenProvider;
5762
6061
  this.WebSocketCtor = options.WebSocketCtor;
5763
- this.httpUrl = this.apiUrl.replace("wss://", "https://").replace("/ws", "");
6062
+ this.onUnexpectedClose = options.onUnexpectedClose;
6063
+ this.onReconnectError = options.onReconnectError;
6064
+ this.httpUrl = this.apiUrl.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://").replace(/\/ws$/, "");
5764
6065
  }
5765
6066
  /**
5766
6067
  * Records/upserts a user and prepares them for sandbox connections
@@ -5844,7 +6145,10 @@ var Granular = class {
5844
6145
  url: this.apiUrl,
5845
6146
  sessionId: envData.environmentId,
5846
6147
  token: this.apiKey,
5847
- WebSocketCtor: this.WebSocketCtor
6148
+ tokenProvider: this.tokenProvider,
6149
+ WebSocketCtor: this.WebSocketCtor,
6150
+ onUnexpectedClose: this.onUnexpectedClose,
6151
+ onReconnectError: this.onReconnectError
5848
6152
  });
5849
6153
  await client.connect();
5850
6154
  const graphqlEndpoint = `${this.httpUrl}/orchestrator/graphql`;