@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/README.md CHANGED
@@ -27,6 +27,26 @@ bun add @granular-software/sdk
27
27
  npm install @granular-software/sdk
28
28
  ```
29
29
 
30
+ ## Endpoint Modes
31
+
32
+ By default, the SDK resolves endpoints like this:
33
+
34
+ - Local mode (`NODE_ENV=development`): `ws://localhost:8787/granular`
35
+ - Production mode (default): `wss://api.granular.dev/v2/ws`
36
+
37
+ Overrides:
38
+
39
+ - SDK option: `endpointMode: 'local' | 'production'`
40
+ - SDK option: `apiUrl: 'ws://... | wss://...'` (highest priority)
41
+ - Env: `GRANULAR_ENDPOINT_MODE=local|production`
42
+ - Env: `GRANULAR_API_URL=...` (highest priority)
43
+
44
+ CLI overrides:
45
+
46
+ - `granular --local <command>`
47
+ - `granular --prod <command>`
48
+ - `granular --env local|production <command>`
49
+
30
50
  ## Quick Start
31
51
 
32
52
  ```typescript
@@ -1,4 +1,4 @@
1
- import { T as ToolWithHandler } from '../types-D5B8WlF4.mjs';
1
+ import { T as ToolWithHandler } from '../types-BOPsFZYi.mjs';
2
2
 
3
3
  /**
4
4
  * Anthropic tool_use block from message response
@@ -1,4 +1,4 @@
1
- import { T as ToolWithHandler } from '../types-D5B8WlF4.js';
1
+ import { T as ToolWithHandler } from '../types-BOPsFZYi.js';
2
2
 
3
3
  /**
4
4
  * Anthropic tool_use block from message response
@@ -1,5 +1,5 @@
1
1
  import { StructuredTool } from '@langchain/core/tools';
2
- import { T as ToolWithHandler } from '../types-D5B8WlF4.mjs';
2
+ import { T as ToolWithHandler } from '../types-BOPsFZYi.mjs';
3
3
 
4
4
  /**
5
5
  * Helper to convert LangChain tools to Granular tools.
@@ -1,5 +1,5 @@
1
1
  import { StructuredTool } from '@langchain/core/tools';
2
- import { T as ToolWithHandler } from '../types-D5B8WlF4.js';
2
+ import { T as ToolWithHandler } from '../types-BOPsFZYi.js';
3
3
 
4
4
  /**
5
5
  * Helper to convert LangChain tools to Granular tools.
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { T as ToolWithHandler } from '../types-D5B8WlF4.mjs';
2
+ import { T as ToolWithHandler } from '../types-BOPsFZYi.mjs';
3
3
 
4
4
  /**
5
5
  * Mastra tool definition interface
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { T as ToolWithHandler } from '../types-D5B8WlF4.js';
2
+ import { T as ToolWithHandler } from '../types-BOPsFZYi.js';
3
3
 
4
4
  /**
5
5
  * Mastra tool definition interface
@@ -1,4 +1,4 @@
1
- import { T as ToolWithHandler } from '../types-D5B8WlF4.mjs';
1
+ import { T as ToolWithHandler } from '../types-BOPsFZYi.mjs';
2
2
 
3
3
  /**
4
4
  * OpenAI tool call from chat completion response
@@ -1,4 +1,4 @@
1
- import { T as ToolWithHandler } from '../types-D5B8WlF4.js';
1
+ import { T as ToolWithHandler } from '../types-BOPsFZYi.js';
2
2
 
3
3
  /**
4
4
  * OpenAI tool call from chat completion response
package/dist/cli/index.js CHANGED
@@ -5384,6 +5384,57 @@ var {
5384
5384
 
5385
5385
  // src/cli/config.ts
5386
5386
  var import_dotenv = __toESM(require_main());
5387
+
5388
+ // src/endpoints.ts
5389
+ var LOCAL_API_URL = "ws://localhost:8787/granular";
5390
+ var PRODUCTION_API_URL = "wss://api.granular.dev/v2/ws";
5391
+ var LOCAL_AUTH_URL = "http://localhost:3000";
5392
+ var PRODUCTION_AUTH_URL = "https://app.granular.software";
5393
+ function readEnv(name) {
5394
+ if (typeof process === "undefined" || !process.env) return void 0;
5395
+ return process.env[name];
5396
+ }
5397
+ function normalizeMode(value) {
5398
+ if (!value) return void 0;
5399
+ const normalized = value.trim().toLowerCase();
5400
+ if (normalized === "local") return "local";
5401
+ if (normalized === "prod" || normalized === "production") return "production";
5402
+ if (normalized === "auto") return "auto";
5403
+ return void 0;
5404
+ }
5405
+ function isTruthy(value) {
5406
+ if (!value) return false;
5407
+ const normalized = value.trim().toLowerCase();
5408
+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
5409
+ }
5410
+ function resolveEndpointMode(explicitMode) {
5411
+ const explicit = normalizeMode(explicitMode);
5412
+ if (explicit === "local" || explicit === "production") {
5413
+ return explicit;
5414
+ }
5415
+ const envMode = normalizeMode(readEnv("GRANULAR_ENDPOINT_MODE") || readEnv("GRANULAR_ENV"));
5416
+ if (envMode === "local" || envMode === "production") {
5417
+ return envMode;
5418
+ }
5419
+ if (isTruthy(readEnv("GRANULAR_USE_LOCAL_ENDPOINTS")) || isTruthy(readEnv("GRANULAR_LOCAL"))) {
5420
+ return "local";
5421
+ }
5422
+ if (isTruthy(readEnv("GRANULAR_USE_PRODUCTION_ENDPOINTS")) || isTruthy(readEnv("GRANULAR_PROD"))) {
5423
+ return "production";
5424
+ }
5425
+ return readEnv("NODE_ENV") === "development" ? "local" : "production";
5426
+ }
5427
+ function resolveApiUrl(explicitApiUrl, mode) {
5428
+ return resolveEndpointMode(mode) === "local" ? LOCAL_API_URL : PRODUCTION_API_URL;
5429
+ }
5430
+ function resolveAuthUrl(explicitAuthUrl, mode) {
5431
+ if (explicitAuthUrl) {
5432
+ return explicitAuthUrl;
5433
+ }
5434
+ return resolveEndpointMode(mode) === "local" ? LOCAL_AUTH_URL : PRODUCTION_AUTH_URL;
5435
+ }
5436
+
5437
+ // src/cli/config.ts
5387
5438
  var MANIFEST_FILE = "granular.json";
5388
5439
  var RC_FILE = ".granularrc";
5389
5440
  var ENV_LOCAL_FILE = ".env.local";
@@ -5453,16 +5504,17 @@ function loadApiKey() {
5453
5504
  return void 0;
5454
5505
  }
5455
5506
  function loadApiUrl() {
5507
+ if (process.env.GRANULAR_API_URL) return process.env.GRANULAR_API_URL;
5508
+ const modeOverride = process.env.GRANULAR_ENDPOINT_MODE;
5509
+ if (modeOverride === "local" || modeOverride === "production" || modeOverride === "prod") {
5510
+ return resolveApiUrl(void 0, modeOverride === "prod" ? "production" : modeOverride);
5511
+ }
5456
5512
  const rc = readRcFile();
5457
5513
  if (rc.apiUrl) return rc.apiUrl;
5458
- if (process.env.GRANULAR_API_URL) return process.env.GRANULAR_API_URL;
5459
- return "https://cf-api-gateway.arthur6084.workers.dev/granular";
5514
+ return resolveApiUrl();
5460
5515
  }
5461
5516
  function loadAuthUrl() {
5462
- if (process.env.GRANULAR_AUTH_URL) {
5463
- return process.env.GRANULAR_AUTH_URL;
5464
- }
5465
- return "https://app.granular.software";
5517
+ return resolveAuthUrl(process.env.GRANULAR_AUTH_URL);
5466
5518
  }
5467
5519
  function saveApiKey(apiKey) {
5468
5520
  const envLocalPath = getEnvLocalPath();
@@ -8623,6 +8675,28 @@ async function simulateCommand(sandboxIdArg) {
8623
8675
  var VERSION = "0.2.0";
8624
8676
  var program2 = new Command();
8625
8677
  program2.name("granular").description("Build and deploy AI sandboxes from code").version(VERSION, "-v, --version");
8678
+ program2.option("--local", "Use local endpoints (localhost)").option("--prod", "Use production endpoints").option("--env <target>", "Endpoint target: local|production");
8679
+ program2.hook("preAction", () => {
8680
+ const opts = program2.opts();
8681
+ const normalizedEnv = opts.env?.trim().toLowerCase();
8682
+ if (opts.local && opts.prod) {
8683
+ error("Cannot use --local and --prod at the same time.");
8684
+ process.exit(1);
8685
+ }
8686
+ if (normalizedEnv && !["local", "production", "prod"].includes(normalizedEnv)) {
8687
+ error('Invalid --env value. Use "local" or "production".');
8688
+ process.exit(1);
8689
+ }
8690
+ let mode;
8691
+ if (opts.local || normalizedEnv === "local") {
8692
+ mode = "local";
8693
+ } else if (opts.prod || normalizedEnv === "production" || normalizedEnv === "prod") {
8694
+ mode = "production";
8695
+ }
8696
+ if (mode) {
8697
+ process.env.GRANULAR_ENDPOINT_MODE = mode;
8698
+ }
8699
+ });
8626
8700
  program2.command("init [project-name]").description("Initialize a new Granular project").option("--skip-build", "Skip the initial build step").action(async (projectName, opts) => {
8627
8701
  try {
8628
8702
  await initCommand(projectName, { skipBuild: opts.skipBuild });
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as Automerge from '@automerge/automerge';
2
- import { W as WSClientOptions, T as ToolWithHandler, P as PublishToolsResult, J as Job, a as ToolHandler, I as InstanceToolHandler, b as ToolInfo, c as ToolsChangedEvent, D as DomainState, G as GranularOptions, R as RecordUserOptions, U as User, C as ConnectOptions, E as EnvironmentData, d as GraphQLResult, e as DefineRelationshipOptions, f as RelationshipInfo, M as ModelRef, g as ManifestContent, h as RecordObjectOptions, i as RecordObjectResult, S as SandboxListResponse, j as Sandbox, k as CreateSandboxData, l as DeleteResponse, m as PermissionProfile, n as CreatePermissionProfileData, o as CreateEnvironmentData, p as Subject, A as AssignmentListResponse } from './types-D5B8WlF4.mjs';
3
- export { a4 as APIError, u as Assignment, z as Build, F as BuildListResponse, B as BuildPolicy, y as BuildStatus, v as EnvironmentListResponse, r as GranularAuth, H as JobStatus, K as JobSubmitResult, w as Manifest, a2 as ManifestImport, x as ManifestListResponse, a1 as ManifestOperation, $ as ManifestPropertySpec, a0 as ManifestRelationshipDef, a3 as ManifestVolume, t as PermissionProfileListResponse, s as PermissionRules, L as Prompt, Q as RPCRequest, Y as RPCRequestFromServer, V as RPCResponse, X as SyncMessage, Z as ToolInvokeParams, _ as ToolResultParams, q as ToolSchema, N as WSDisconnectInfo, O as WSReconnectErrorInfo } from './types-D5B8WlF4.mjs';
2
+ import { W as WSClientOptions, T as ToolWithHandler, P as PublishToolsResult, J as Job, a as ToolHandler, I as InstanceToolHandler, b as ToolInfo, c as ToolsChangedEvent, D as DomainState, G as GranularOptions, R as RecordUserOptions, U as User, C as ConnectOptions, E as EnvironmentData, d as GraphQLResult, e as DefineRelationshipOptions, f as RelationshipInfo, M as ModelRef, g as ManifestContent, h as RecordObjectOptions, i as RecordObjectResult, S as SandboxListResponse, j as Sandbox, k as CreateSandboxData, l as DeleteResponse, m as PermissionProfile, n as CreatePermissionProfileData, o as CreateEnvironmentData, p as Subject, A as AssignmentListResponse } from './types-BOPsFZYi.mjs';
3
+ export { a6 as APIError, r as AccessTokenProvider, w as Assignment, H as Build, K as BuildListResponse, B as BuildPolicy, F as BuildStatus, s as EndpointMode, x as EnvironmentListResponse, t as GranularAuth, L as JobStatus, N as JobSubmitResult, y as Manifest, a4 as ManifestImport, z as ManifestListResponse, a3 as ManifestOperation, a1 as ManifestPropertySpec, a2 as ManifestRelationshipDef, a5 as ManifestVolume, v as PermissionProfileListResponse, u as PermissionRules, O as Prompt, X as RPCRequest, _ as RPCRequestFromServer, Y as RPCResponse, Z as SyncMessage, $ as ToolInvokeParams, a0 as ToolResultParams, q as ToolSchema, Q as WSDisconnectInfo, V as WSReconnectErrorInfo } from './types-BOPsFZYi.mjs';
4
4
  import { Doc } from '@automerge/automerge/slim';
5
5
 
6
6
  declare class WSClient {
@@ -16,9 +16,16 @@ declare class WSClient {
16
16
  doc: Automerge.Doc<Record<string, unknown>>;
17
17
  private syncState;
18
18
  private reconnectTimer;
19
+ private tokenRefreshTimer;
19
20
  private isExplicitlyDisconnected;
20
21
  private options;
21
22
  constructor(options: WSClientOptions);
23
+ private clearTokenRefreshTimer;
24
+ private decodeBase64Url;
25
+ private getTokenExpiryMs;
26
+ private scheduleTokenRefresh;
27
+ private refreshTokenInBackground;
28
+ private resolveTokenForConnect;
22
29
  /**
23
30
  * Connect to the WebSocket server
24
31
  * @returns {Promise<void>} Resolves when connection is open
@@ -325,6 +332,15 @@ declare class Environment extends Session {
325
332
  get permissionProfileId(): string;
326
333
  /** The GraphQL API endpoint URL */
327
334
  get apiEndpoint(): string;
335
+ private getRuntimeBaseUrl;
336
+ /**
337
+ * Close the session and disconnect from the sandbox.
338
+ *
339
+ * Sends `client.goodbye` over WebSocket first, then issues an HTTP fallback
340
+ * to the runtime goodbye endpoint if no definitive WS-side runtime notify
341
+ * acknowledgement was observed.
342
+ */
343
+ disconnect(): Promise<void>;
328
344
  /** The last known graph container status, updated by checkReadiness() or on heartbeat */
329
345
  graphContainerStatus: {
330
346
  lastKeepAliveAt: number;
@@ -640,6 +656,7 @@ declare class Granular {
640
656
  private apiKey;
641
657
  private apiUrl;
642
658
  private httpUrl;
659
+ private tokenProvider?;
643
660
  private WebSocketCtor?;
644
661
  private onUnexpectedClose?;
645
662
  private onReconnectError?;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as Automerge from '@automerge/automerge';
2
- import { W as WSClientOptions, T as ToolWithHandler, P as PublishToolsResult, J as Job, a as ToolHandler, I as InstanceToolHandler, b as ToolInfo, c as ToolsChangedEvent, D as DomainState, G as GranularOptions, R as RecordUserOptions, U as User, C as ConnectOptions, E as EnvironmentData, d as GraphQLResult, e as DefineRelationshipOptions, f as RelationshipInfo, M as ModelRef, g as ManifestContent, h as RecordObjectOptions, i as RecordObjectResult, S as SandboxListResponse, j as Sandbox, k as CreateSandboxData, l as DeleteResponse, m as PermissionProfile, n as CreatePermissionProfileData, o as CreateEnvironmentData, p as Subject, A as AssignmentListResponse } from './types-D5B8WlF4.js';
3
- export { a4 as APIError, u as Assignment, z as Build, F as BuildListResponse, B as BuildPolicy, y as BuildStatus, v as EnvironmentListResponse, r as GranularAuth, H as JobStatus, K as JobSubmitResult, w as Manifest, a2 as ManifestImport, x as ManifestListResponse, a1 as ManifestOperation, $ as ManifestPropertySpec, a0 as ManifestRelationshipDef, a3 as ManifestVolume, t as PermissionProfileListResponse, s as PermissionRules, L as Prompt, Q as RPCRequest, Y as RPCRequestFromServer, V as RPCResponse, X as SyncMessage, Z as ToolInvokeParams, _ as ToolResultParams, q as ToolSchema, N as WSDisconnectInfo, O as WSReconnectErrorInfo } from './types-D5B8WlF4.js';
2
+ import { W as WSClientOptions, T as ToolWithHandler, P as PublishToolsResult, J as Job, a as ToolHandler, I as InstanceToolHandler, b as ToolInfo, c as ToolsChangedEvent, D as DomainState, G as GranularOptions, R as RecordUserOptions, U as User, C as ConnectOptions, E as EnvironmentData, d as GraphQLResult, e as DefineRelationshipOptions, f as RelationshipInfo, M as ModelRef, g as ManifestContent, h as RecordObjectOptions, i as RecordObjectResult, S as SandboxListResponse, j as Sandbox, k as CreateSandboxData, l as DeleteResponse, m as PermissionProfile, n as CreatePermissionProfileData, o as CreateEnvironmentData, p as Subject, A as AssignmentListResponse } from './types-BOPsFZYi.js';
3
+ export { a6 as APIError, r as AccessTokenProvider, w as Assignment, H as Build, K as BuildListResponse, B as BuildPolicy, F as BuildStatus, s as EndpointMode, x as EnvironmentListResponse, t as GranularAuth, L as JobStatus, N as JobSubmitResult, y as Manifest, a4 as ManifestImport, z as ManifestListResponse, a3 as ManifestOperation, a1 as ManifestPropertySpec, a2 as ManifestRelationshipDef, a5 as ManifestVolume, v as PermissionProfileListResponse, u as PermissionRules, O as Prompt, X as RPCRequest, _ as RPCRequestFromServer, Y as RPCResponse, Z as SyncMessage, $ as ToolInvokeParams, a0 as ToolResultParams, q as ToolSchema, Q as WSDisconnectInfo, V as WSReconnectErrorInfo } from './types-BOPsFZYi.js';
4
4
  import { Doc } from '@automerge/automerge/slim';
5
5
 
6
6
  declare class WSClient {
@@ -16,9 +16,16 @@ declare class WSClient {
16
16
  doc: Automerge.Doc<Record<string, unknown>>;
17
17
  private syncState;
18
18
  private reconnectTimer;
19
+ private tokenRefreshTimer;
19
20
  private isExplicitlyDisconnected;
20
21
  private options;
21
22
  constructor(options: WSClientOptions);
23
+ private clearTokenRefreshTimer;
24
+ private decodeBase64Url;
25
+ private getTokenExpiryMs;
26
+ private scheduleTokenRefresh;
27
+ private refreshTokenInBackground;
28
+ private resolveTokenForConnect;
22
29
  /**
23
30
  * Connect to the WebSocket server
24
31
  * @returns {Promise<void>} Resolves when connection is open
@@ -325,6 +332,15 @@ declare class Environment extends Session {
325
332
  get permissionProfileId(): string;
326
333
  /** The GraphQL API endpoint URL */
327
334
  get apiEndpoint(): string;
335
+ private getRuntimeBaseUrl;
336
+ /**
337
+ * Close the session and disconnect from the sandbox.
338
+ *
339
+ * Sends `client.goodbye` over WebSocket first, then issues an HTTP fallback
340
+ * to the runtime goodbye endpoint if no definitive WS-side runtime notify
341
+ * acknowledgement was observed.
342
+ */
343
+ disconnect(): Promise<void>;
328
344
  /** The last known graph container status, updated by checkReadiness() or on heartbeat */
329
345
  graphContainerStatus: {
330
346
  lastKeepAliveAt: number;
@@ -640,6 +656,7 @@ declare class Granular {
640
656
  private apiKey;
641
657
  private apiUrl;
642
658
  private httpUrl;
659
+ private tokenProvider?;
643
660
  private WebSocketCtor?;
644
661
  private onUnexpectedClose?;
645
662
  private onReconnectError?;
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,109 @@ 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();
3969
4070
  if (this.reconnectTimer) {
3970
4071
  clearTimeout(this.reconnectTimer);
3971
4072
  this.reconnectTimer = null;
@@ -3985,7 +4086,7 @@ var WSClient = class {
3985
4086
  try {
3986
4087
  const wsUrl = new URL(this.url);
3987
4088
  wsUrl.searchParams.set("sessionId", this.sessionId);
3988
- wsUrl.searchParams.set("token", this.token);
4089
+ wsUrl.searchParams.set("token", token);
3989
4090
  this.ws = new WebSocketClass(wsUrl.toString());
3990
4091
  if (!this.ws) throw new Error("Failed to create WebSocket");
3991
4092
  const socket = this.ws;
@@ -4190,10 +4291,10 @@ var WSClient = class {
4190
4291
  const snapshotMessage = message;
4191
4292
  try {
4192
4293
  const bytes = new Uint8Array(snapshotMessage.data);
4193
- console.log("[Granular DEBUG] Loading snapshot bytes:", bytes.length);
4294
+ console.log("[Granular DEBUG] Loading Automerge session snapshot bytes:", bytes.length);
4194
4295
  this.doc = Automerge__namespace.load(bytes);
4195
4296
  this.emit("sync", this.doc);
4196
- 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)));
4197
4298
  } catch (e) {
4198
4299
  console.warn("[Granular] Failed to load snapshot message", e);
4199
4300
  }
@@ -4356,6 +4457,7 @@ var WSClient = class {
4356
4457
  clearTimeout(this.reconnectTimer);
4357
4458
  this.reconnectTimer = null;
4358
4459
  }
4460
+ this.clearTokenRefreshTimer();
4359
4461
  if (this.ws) {
4360
4462
  this.ws.close(1e3, "Client disconnect");
4361
4463
  this.ws = null;
@@ -4842,6 +4944,13 @@ import { ${allImports} } from "./sandbox-tools";
4842
4944
  * Close the session and disconnect from the sandbox
4843
4945
  */
4844
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
+ }
4845
4954
  this.client.disconnect();
4846
4955
  }
4847
4956
  // --- Event Handling ---
@@ -5052,6 +5161,50 @@ var JobImplementation = class {
5052
5161
  }
5053
5162
  };
5054
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
+
5055
5208
  // src/client.ts
5056
5209
  var STANDARD_MODULES_OPERATIONS = [
5057
5210
  { create: "entity", has: { id: { value: "auto-generated" }, createdAt: { value: void 0 } } },
@@ -5096,6 +5249,60 @@ var Environment = class _Environment extends Session {
5096
5249
  get apiEndpoint() {
5097
5250
  return this._apiEndpoint;
5098
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
+ }
5099
5306
  // ==================== GRAPH CONTAINER READINESS ====================
5100
5307
  /** The last known graph container status, updated by checkReadiness() or on heartbeat */
5101
5308
  graphContainerStatus = null;
@@ -5831,6 +6038,7 @@ var Granular = class {
5831
6038
  apiKey;
5832
6039
  apiUrl;
5833
6040
  httpUrl;
6041
+ tokenProvider;
5834
6042
  WebSocketCtor;
5835
6043
  onUnexpectedClose;
5836
6044
  onReconnectError;
@@ -5848,11 +6056,12 @@ var Granular = class {
5848
6056
  throw new Error("Granular client requires either apiKey or token. Set GRANULAR_API_KEY or GRANULAR_TOKEN, or pass one in options.");
5849
6057
  }
5850
6058
  this.apiKey = auth;
5851
- this.apiUrl = options.apiUrl || "wss://api.granular.dev/v2/ws";
6059
+ this.apiUrl = resolveApiUrl(options.apiUrl, options.endpointMode);
6060
+ this.tokenProvider = options.tokenProvider;
5852
6061
  this.WebSocketCtor = options.WebSocketCtor;
5853
6062
  this.onUnexpectedClose = options.onUnexpectedClose;
5854
6063
  this.onReconnectError = options.onReconnectError;
5855
- this.httpUrl = this.apiUrl.replace("wss://", "https://").replace("/ws", "");
6064
+ this.httpUrl = this.apiUrl.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://").replace(/\/ws$/, "");
5856
6065
  }
5857
6066
  /**
5858
6067
  * Records/upserts a user and prepares them for sandbox connections
@@ -5936,6 +6145,7 @@ var Granular = class {
5936
6145
  url: this.apiUrl,
5937
6146
  sessionId: envData.environmentId,
5938
6147
  token: this.apiKey,
6148
+ tokenProvider: this.tokenProvider,
5939
6149
  WebSocketCtor: this.WebSocketCtor,
5940
6150
  onUnexpectedClose: this.onUnexpectedClose,
5941
6151
  onReconnectError: this.onReconnectError