@sylphx/sdk 0.0.1 → 0.2.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
@@ -39,6 +39,7 @@ __export(index_exports, {
39
39
  NotFoundError: () => NotFoundError,
40
40
  RETRYABLE_CODES: () => RETRYABLE_CODES,
41
41
  RateLimitError: () => RateLimitError,
42
+ SandboxClient: () => SandboxClient,
42
43
  SylphxError: () => SylphxError,
43
44
  TimeoutError: () => TimeoutError,
44
45
  ValidationError: () => ValidationError,
@@ -232,7 +233,7 @@ module.exports = __toCommonJS(index_exports);
232
233
 
233
234
  // src/constants.ts
234
235
  var DEFAULT_PLATFORM_URL = "https://sylphx.com";
235
- var SDK_API_PATH = `/api/app/v1`;
236
+ var SDK_API_PATH = `/api/v1`;
236
237
  var SDK_API_PATH_NEW = `/v1`;
237
238
  var DEFAULT_SDK_API_HOST = "api.sylphx.com";
238
239
  var SDK_VERSION = "0.1.0";
@@ -683,6 +684,7 @@ function httpStatusToErrorCode(status) {
683
684
  return status >= 500 ? "INTERNAL_SERVER_ERROR" : "BAD_REQUEST";
684
685
  }
685
686
  }
687
+ var REF_PATTERN = /^[a-z0-9]{16}$/;
686
688
  function createConfig(input) {
687
689
  let secretKey;
688
690
  if (input.secretKey) {
@@ -698,13 +700,22 @@ function createConfig(input) {
698
700
  }
699
701
  secretKey = result.sanitizedKey;
700
702
  }
703
+ if (input.ref !== void 0) {
704
+ const trimmedRef = input.ref.trim();
705
+ if (!REF_PATTERN.test(trimmedRef)) {
706
+ throw new SylphxError(
707
+ `[Sylphx] Invalid project ref format: "${input.ref}". Expected a 16-character lowercase alphanumeric string (e.g. "abc123def456ghij"). Get your ref from Platform Console \u2192 Projects \u2192 Your Project \u2192 Overview.`,
708
+ { code: "BAD_REQUEST" }
709
+ );
710
+ }
711
+ }
701
712
  let platformUrl;
702
713
  let apiBasePath;
703
714
  if (input.platformUrl) {
704
715
  platformUrl = input.platformUrl.trim();
705
716
  apiBasePath = SDK_API_PATH;
706
717
  } else if (input.ref) {
707
- platformUrl = `https://${input.ref}.${DEFAULT_SDK_API_HOST}`;
718
+ platformUrl = `https://${input.ref.trim()}.${DEFAULT_SDK_API_HOST}`;
708
719
  apiBasePath = SDK_API_PATH_NEW;
709
720
  } else {
710
721
  platformUrl = DEFAULT_PLATFORM_URL;
@@ -1008,6 +1019,7 @@ function createDeduplicationMiddleware(config = {}) {
1008
1019
  deduped._dedupKey = key;
1009
1020
  return deduped;
1010
1021
  }
1022
+ ;
1011
1023
  request._dedupKey = key;
1012
1024
  return request;
1013
1025
  },
@@ -1031,30 +1043,24 @@ function createDeduplicationMiddleware(config = {}) {
1031
1043
  var CircuitBreakerOpenError = class extends Error {
1032
1044
  remainingMs;
1033
1045
  constructor(remainingMs) {
1034
- super(
1035
- `Circuit breaker is open. Retry after ${Math.ceil(remainingMs / 1e3)}s`
1036
- );
1046
+ super(`Circuit breaker is open. Retry after ${Math.ceil(remainingMs / 1e3)}s`);
1037
1047
  this.name = "CircuitBreakerOpenError";
1038
1048
  this.remainingMs = remainingMs;
1039
1049
  }
1040
1050
  };
1041
- var circuitBreaker = null;
1042
- function getCircuitBreaker(config = {}) {
1043
- if (!circuitBreaker) {
1044
- circuitBreaker = {
1045
- state: "CLOSED",
1046
- failures: [],
1047
- openedAt: null,
1048
- config: {
1049
- enabled: config.enabled ?? true,
1050
- failureThreshold: config.failureThreshold ?? CIRCUIT_BREAKER_FAILURE_THRESHOLD,
1051
- windowMs: config.windowMs ?? CIRCUIT_BREAKER_WINDOW_MS,
1052
- openDurationMs: config.openDurationMs ?? CIRCUIT_BREAKER_OPEN_DURATION_MS,
1053
- isFailure: config.isFailure ?? ((status) => status >= 500 || status === 429)
1054
- }
1055
- };
1056
- }
1057
- return circuitBreaker;
1051
+ function createCircuitBreakerInstance(config = {}) {
1052
+ return {
1053
+ state: "CLOSED",
1054
+ failures: [],
1055
+ openedAt: null,
1056
+ config: {
1057
+ enabled: config.enabled ?? true,
1058
+ failureThreshold: config.failureThreshold ?? CIRCUIT_BREAKER_FAILURE_THRESHOLD,
1059
+ windowMs: config.windowMs ?? CIRCUIT_BREAKER_WINDOW_MS,
1060
+ openDurationMs: config.openDurationMs ?? CIRCUIT_BREAKER_OPEN_DURATION_MS,
1061
+ isFailure: config.isFailure ?? ((status) => status >= 500 || status === 429)
1062
+ }
1063
+ };
1058
1064
  }
1059
1065
  function recordFailure(cb) {
1060
1066
  const now = Date.now();
@@ -1102,7 +1108,8 @@ function createCircuitBreakerMiddleware(config) {
1102
1108
  }
1103
1109
  };
1104
1110
  }
1105
- const cb = getCircuitBreaker(config ?? {});
1111
+ const cb = createCircuitBreakerInstance(config ?? {});
1112
+ _lastCircuitBreaker = cb;
1106
1113
  return {
1107
1114
  async onRequest({ request }) {
1108
1115
  if (!cb.config.enabled) {
@@ -1127,15 +1134,21 @@ function createCircuitBreakerMiddleware(config) {
1127
1134
  }
1128
1135
  };
1129
1136
  }
1137
+ var _lastCircuitBreaker = null;
1130
1138
  function resetCircuitBreaker() {
1131
- circuitBreaker = null;
1139
+ if (_lastCircuitBreaker) {
1140
+ _lastCircuitBreaker.state = "CLOSED";
1141
+ _lastCircuitBreaker.failures = [];
1142
+ _lastCircuitBreaker.openedAt = null;
1143
+ }
1144
+ _lastCircuitBreaker = null;
1132
1145
  }
1133
1146
  function getCircuitBreakerState() {
1134
- if (!circuitBreaker) return null;
1147
+ if (!_lastCircuitBreaker) return null;
1135
1148
  return {
1136
- state: circuitBreaker.state,
1137
- failures: circuitBreaker.failures.length,
1138
- openedAt: circuitBreaker.openedAt
1149
+ state: _lastCircuitBreaker.state,
1150
+ failures: _lastCircuitBreaker.failures.length,
1151
+ openedAt: _lastCircuitBreaker.openedAt
1139
1152
  };
1140
1153
  }
1141
1154
  var etagCache = /* @__PURE__ */ new Map();
@@ -1227,6 +1240,7 @@ function createETagMiddleware(config) {
1227
1240
  }
1228
1241
  };
1229
1242
  }
1243
+ var retryBodyMap = /* @__PURE__ */ new WeakMap();
1230
1244
  function createRetryMiddleware(retryConfig) {
1231
1245
  if (retryConfig === false) {
1232
1246
  return {
@@ -1242,27 +1256,22 @@ function createRetryMiddleware(retryConfig) {
1242
1256
  shouldRetry = isRetryableStatus,
1243
1257
  timeout = DEFAULT_TIMEOUT_MS
1244
1258
  } = retryConfig ?? {};
1245
- let originalBody = null;
1246
1259
  return {
1247
1260
  async onRequest({ request }) {
1248
- if (request.body) {
1249
- originalBody = await request.clone().text();
1250
- } else {
1251
- originalBody = null;
1252
- }
1253
- if (!request.signal) {
1254
- const controller = new AbortController();
1255
- setTimeout(() => controller.abort(), timeout);
1256
- return new Request(request.url, {
1257
- method: request.method,
1258
- headers: request.headers,
1259
- body: originalBody,
1260
- signal: controller.signal
1261
- });
1262
- }
1263
- return request;
1261
+ const body = request.body ? await request.clone().text() : null;
1262
+ const controller = new AbortController();
1263
+ setTimeout(() => controller.abort(), timeout);
1264
+ const newRequest = new Request(request.url, {
1265
+ method: request.method,
1266
+ headers: request.headers,
1267
+ body,
1268
+ signal: controller.signal
1269
+ });
1270
+ retryBodyMap.set(newRequest, body);
1271
+ return newRequest;
1264
1272
  },
1265
1273
  async onResponse({ response, request }) {
1274
+ const originalBody = retryBodyMap.get(request) ?? null;
1266
1275
  let attempt = 0;
1267
1276
  let currentResponse = response;
1268
1277
  while (attempt < maxRetries && shouldRetry(currentResponse.status, attempt)) {
@@ -1282,16 +1291,19 @@ function createRetryMiddleware(retryConfig) {
1282
1291
  const newResponse = await fetch(retryRequest);
1283
1292
  clearTimeout(timeoutId);
1284
1293
  if (newResponse.ok || !shouldRetry(newResponse.status, attempt)) {
1294
+ retryBodyMap.delete(request);
1285
1295
  return newResponse;
1286
1296
  }
1287
1297
  currentResponse = newResponse;
1288
1298
  } catch (error) {
1289
1299
  clearTimeout(timeoutId);
1290
1300
  if (attempt >= maxRetries) {
1301
+ retryBodyMap.delete(request);
1291
1302
  throw error;
1292
1303
  }
1293
1304
  }
1294
1305
  }
1306
+ retryBodyMap.delete(request);
1295
1307
  return currentResponse;
1296
1308
  }
1297
1309
  };
@@ -1380,35 +1392,20 @@ async function signOut(config) {
1380
1392
  await callApi(config, "/auth/logout", { method: "POST" });
1381
1393
  }
1382
1394
  async function refreshToken(config, token) {
1383
- const response = await fetch(`${config.platformUrl}/api/v1/auth/token`, {
1395
+ return callApi(config, "/auth/token", {
1384
1396
  method: "POST",
1385
- headers: { "Content-Type": "application/json" },
1386
- body: JSON.stringify({
1397
+ body: {
1387
1398
  grant_type: "refresh_token",
1388
1399
  refresh_token: token,
1389
1400
  client_secret: config.secretKey
1390
- })
1401
+ }
1391
1402
  });
1392
- if (!response.ok) {
1393
- const error = await response.json().catch(() => ({ message: "Token refresh failed" }));
1394
- throw new SylphxError(error.message ?? "Token refresh failed", {
1395
- code: "UNAUTHORIZED"
1396
- });
1397
- }
1398
- return response.json();
1399
1403
  }
1400
1404
  async function verifyEmail(config, token) {
1401
- const response = await fetch(`${config.platformUrl}/api/v1/auth/verify-email`, {
1405
+ await callApi(config, "/auth/verify-email", {
1402
1406
  method: "POST",
1403
- headers: buildHeaders(config),
1404
- body: JSON.stringify({ token })
1407
+ body: { token }
1405
1408
  });
1406
- if (!response.ok) {
1407
- const error = await response.json().catch(() => ({ message: "Email verification failed" }));
1408
- throw new SylphxError(error.message ?? "Email verification failed", {
1409
- code: "BAD_REQUEST"
1410
- });
1411
- }
1412
1409
  }
1413
1410
  async function forgotPassword(config, email) {
1414
1411
  await callApi(config, "/auth/forgot-password", {
@@ -3088,6 +3085,172 @@ async function captureMessage(config, message, options = {}) {
3088
3085
  body: request
3089
3086
  });
3090
3087
  }
3088
+
3089
+ // src/sandbox.ts
3090
+ var DEFAULT_IMAGE = "registry.sylphx.com/library/exec-server:latest";
3091
+ var SandboxClient = class _SandboxClient {
3092
+ id;
3093
+ config;
3094
+ constructor(id, config) {
3095
+ this.id = id;
3096
+ this.config = config;
3097
+ }
3098
+ // ---------------------------------------------------------------------------
3099
+ // Factory
3100
+ // ---------------------------------------------------------------------------
3101
+ /**
3102
+ * Create a new sandbox.
3103
+ *
3104
+ * The sandbox pod starts asynchronously — it will be in `starting` status
3105
+ * after creation. File/run operations will block until the pod is ready.
3106
+ *
3107
+ * @param config - Sylphx config with a secret key (sk_*) and project ref
3108
+ * @param options - Sandbox creation options
3109
+ */
3110
+ static async create(config, options) {
3111
+ const record = await callApi(config, `/sandboxes`, {
3112
+ method: "POST",
3113
+ body: {
3114
+ image: options?.image ?? DEFAULT_IMAGE,
3115
+ idleTimeoutMs: options?.idleTimeoutMs ?? 3e5,
3116
+ resources: options?.resources,
3117
+ env: options?.env,
3118
+ storage: options?.storageGi !== void 0 ? { enabled: true, sizeGi: options.storageGi } : void 0
3119
+ }
3120
+ });
3121
+ return new _SandboxClient(record.id, config);
3122
+ }
3123
+ /**
3124
+ * Reconnect to an existing sandbox by ID.
3125
+ *
3126
+ * Use this to resume operations on a sandbox created in a previous request.
3127
+ */
3128
+ static fromId(config, sandboxId) {
3129
+ return new _SandboxClient(sandboxId, config);
3130
+ }
3131
+ // ---------------------------------------------------------------------------
3132
+ // Lifecycle
3133
+ // ---------------------------------------------------------------------------
3134
+ /**
3135
+ * Get the current status of this sandbox.
3136
+ */
3137
+ async getStatus() {
3138
+ return callApi(
3139
+ this.config,
3140
+ `/sandboxes/${this.id}`,
3141
+ { method: "GET" }
3142
+ );
3143
+ }
3144
+ /**
3145
+ * Terminate the sandbox immediately.
3146
+ *
3147
+ * Deletes the K8s Pod and Service. PVC (storage) is preserved for reuse.
3148
+ * This operation is idempotent — safe to call multiple times.
3149
+ */
3150
+ async terminate() {
3151
+ await callApi(
3152
+ this.config,
3153
+ `/sandboxes/${this.id}`,
3154
+ { method: "DELETE" }
3155
+ );
3156
+ }
3157
+ // ---------------------------------------------------------------------------
3158
+ // File Operations
3159
+ // ---------------------------------------------------------------------------
3160
+ /**
3161
+ * Write a file to the sandbox filesystem.
3162
+ *
3163
+ * @param path - Absolute path inside the sandbox (e.g. '/workspace/file.py')
3164
+ * @param content - File content as string or Buffer
3165
+ * @param encoding - 'utf8' (default) or 'base64' for binary files
3166
+ */
3167
+ async writeFile(path, content, encoding = "utf8") {
3168
+ const contentStr = Buffer.isBuffer(content) ? content.toString("base64") : content;
3169
+ const effectiveEncoding = Buffer.isBuffer(content) ? "base64" : encoding;
3170
+ await callApi(
3171
+ this.config,
3172
+ `/sandboxes/${this.id}/files`,
3173
+ {
3174
+ method: "POST",
3175
+ body: { path, content: contentStr, encoding: effectiveEncoding }
3176
+ }
3177
+ );
3178
+ }
3179
+ /**
3180
+ * Read a file from the sandbox filesystem.
3181
+ *
3182
+ * @param path - Absolute path inside the sandbox
3183
+ * @returns File content as a string
3184
+ */
3185
+ async readFile(path) {
3186
+ const result = await callApi(
3187
+ this.config,
3188
+ `/sandboxes/${this.id}/files`,
3189
+ {
3190
+ method: "GET",
3191
+ query: { path }
3192
+ }
3193
+ );
3194
+ return result.content;
3195
+ }
3196
+ /**
3197
+ * Delete a file from the sandbox filesystem.
3198
+ *
3199
+ * @param path - Absolute path inside the sandbox
3200
+ */
3201
+ async deleteFile(path) {
3202
+ await callApi(
3203
+ this.config,
3204
+ `/sandboxes/${this.id}/files`,
3205
+ {
3206
+ method: "DELETE",
3207
+ query: { path }
3208
+ }
3209
+ );
3210
+ }
3211
+ /**
3212
+ * List files in a directory.
3213
+ *
3214
+ * @param path - Directory path inside the sandbox (default: '/')
3215
+ * @returns Array of file/directory paths
3216
+ */
3217
+ async listFiles(path = "/") {
3218
+ const result = await callApi(
3219
+ this.config,
3220
+ `/sandboxes/${this.id}/list`,
3221
+ {
3222
+ method: "GET",
3223
+ query: { path }
3224
+ }
3225
+ );
3226
+ return result.files;
3227
+ }
3228
+ // ---------------------------------------------------------------------------
3229
+ // Command Execution
3230
+ // ---------------------------------------------------------------------------
3231
+ /**
3232
+ * Run a command inside the sandbox.
3233
+ *
3234
+ * The command runs synchronously inside the exec pod and returns when complete.
3235
+ *
3236
+ * @param command - Full command + args as array (e.g. ['python3', 'script.py'])
3237
+ * @param options - Optional cwd, env, timeout, stdin
3238
+ * @returns { stdout, stderr, exitCode, durationMs }
3239
+ *
3240
+ * @example
3241
+ * ```typescript
3242
+ * const { stdout, exitCode } = await sandbox.run(['python3', '-c', 'print(1+1)'])
3243
+ * console.log(stdout) // "2\n"
3244
+ * console.log(exitCode) // 0
3245
+ * ```
3246
+ */
3247
+ async run(command, options) {
3248
+ return callApi(this.config, `/sandboxes/${this.id}/run`, {
3249
+ method: "POST",
3250
+ body: { command, ...options }
3251
+ });
3252
+ }
3253
+ };
3091
3254
  // Annotate the CommonJS export names for ESM import in node:
3092
3255
  0 && (module.exports = {
3093
3256
  ACHIEVEMENT_TIER_CONFIG,
@@ -3099,6 +3262,7 @@ async function captureMessage(config, message, options = {}) {
3099
3262
  NotFoundError,
3100
3263
  RETRYABLE_CODES,
3101
3264
  RateLimitError,
3265
+ SandboxClient,
3102
3266
  SylphxError,
3103
3267
  TimeoutError,
3104
3268
  ValidationError,