@granular-software/sdk 0.3.4 → 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,109 @@ 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();
3947
4048
  if (this.reconnectTimer) {
3948
4049
  clearTimeout(this.reconnectTimer);
3949
4050
  this.reconnectTimer = null;
@@ -3963,7 +4064,7 @@ var WSClient = class {
3963
4064
  try {
3964
4065
  const wsUrl = new URL(this.url);
3965
4066
  wsUrl.searchParams.set("sessionId", this.sessionId);
3966
- wsUrl.searchParams.set("token", this.token);
4067
+ wsUrl.searchParams.set("token", token);
3967
4068
  this.ws = new WebSocketClass(wsUrl.toString());
3968
4069
  if (!this.ws) throw new Error("Failed to create WebSocket");
3969
4070
  const socket = this.ws;
@@ -4168,10 +4269,10 @@ var WSClient = class {
4168
4269
  const snapshotMessage = message;
4169
4270
  try {
4170
4271
  const bytes = new Uint8Array(snapshotMessage.data);
4171
- console.log("[Granular DEBUG] Loading snapshot bytes:", bytes.length);
4272
+ console.log("[Granular DEBUG] Loading Automerge session snapshot bytes:", bytes.length);
4172
4273
  this.doc = Automerge.load(bytes);
4173
4274
  this.emit("sync", this.doc);
4174
- 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)));
4175
4276
  } catch (e) {
4176
4277
  console.warn("[Granular] Failed to load snapshot message", e);
4177
4278
  }
@@ -4334,6 +4435,7 @@ var WSClient = class {
4334
4435
  clearTimeout(this.reconnectTimer);
4335
4436
  this.reconnectTimer = null;
4336
4437
  }
4438
+ this.clearTokenRefreshTimer();
4337
4439
  if (this.ws) {
4338
4440
  this.ws.close(1e3, "Client disconnect");
4339
4441
  this.ws = null;
@@ -4820,6 +4922,13 @@ import { ${allImports} } from "./sandbox-tools";
4820
4922
  * Close the session and disconnect from the sandbox
4821
4923
  */
4822
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
+ }
4823
4932
  this.client.disconnect();
4824
4933
  }
4825
4934
  // --- Event Handling ---
@@ -5030,6 +5139,50 @@ var JobImplementation = class {
5030
5139
  }
5031
5140
  };
5032
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
+
5033
5186
  // src/client.ts
5034
5187
  var STANDARD_MODULES_OPERATIONS = [
5035
5188
  { create: "entity", has: { id: { value: "auto-generated" }, createdAt: { value: void 0 } } },
@@ -5074,6 +5227,60 @@ var Environment = class _Environment extends Session {
5074
5227
  get apiEndpoint() {
5075
5228
  return this._apiEndpoint;
5076
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
+ }
5077
5284
  // ==================== GRAPH CONTAINER READINESS ====================
5078
5285
  /** The last known graph container status, updated by checkReadiness() or on heartbeat */
5079
5286
  graphContainerStatus = null;
@@ -5809,6 +6016,7 @@ var Granular = class {
5809
6016
  apiKey;
5810
6017
  apiUrl;
5811
6018
  httpUrl;
6019
+ tokenProvider;
5812
6020
  WebSocketCtor;
5813
6021
  onUnexpectedClose;
5814
6022
  onReconnectError;
@@ -5826,11 +6034,12 @@ var Granular = class {
5826
6034
  throw new Error("Granular client requires either apiKey or token. Set GRANULAR_API_KEY or GRANULAR_TOKEN, or pass one in options.");
5827
6035
  }
5828
6036
  this.apiKey = auth;
5829
- this.apiUrl = options.apiUrl || "wss://api.granular.dev/v2/ws";
6037
+ this.apiUrl = resolveApiUrl(options.apiUrl, options.endpointMode);
6038
+ this.tokenProvider = options.tokenProvider;
5830
6039
  this.WebSocketCtor = options.WebSocketCtor;
5831
6040
  this.onUnexpectedClose = options.onUnexpectedClose;
5832
6041
  this.onReconnectError = options.onReconnectError;
5833
- this.httpUrl = this.apiUrl.replace("wss://", "https://").replace("/ws", "");
6042
+ this.httpUrl = this.apiUrl.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://").replace(/\/ws$/, "");
5834
6043
  }
5835
6044
  /**
5836
6045
  * Records/upserts a user and prepares them for sandbox connections
@@ -5914,6 +6123,7 @@ var Granular = class {
5914
6123
  url: this.apiUrl,
5915
6124
  sessionId: envData.environmentId,
5916
6125
  token: this.apiKey,
6126
+ tokenProvider: this.tokenProvider,
5917
6127
  WebSocketCtor: this.WebSocketCtor,
5918
6128
  onUnexpectedClose: this.onUnexpectedClose,
5919
6129
  onReconnectError: this.onReconnectError