Make async JS SDK isomorphic (Node.js + Browser)
- Add environment detection (IS_BROWSER, IS_NODE) - Use top-level await for conditional Node.js imports - Make signRequest async/isomorphic (Node crypto vs Web Crypto API) - Add loadCredentialsFromStorage() for browser localStorage support - Update resolveCredentials() with 5-tier priority including localStorage - Add IS_NODE guards to file-system dependent functions - Guard CLI entry point to prevent browser errors - Update header docs with browser usage instructions All 71 existing tests pass.
This commit is contained in:
parent
5c14ccc3af
commit
2e44723e8e
1 changed files with 153 additions and 57 deletions
|
|
@ -2,6 +2,7 @@
|
|||
* PUBLIC DOMAIN - NO LICENSE, NO WARRANTY
|
||||
*
|
||||
* unsandbox.com JavaScript SDK (Asynchronous with native fetch)
|
||||
* Isomorphic: Works in Node.js (CLI + SDK) and Browser environments
|
||||
*
|
||||
* Library Usage:
|
||||
* import {
|
||||
|
|
@ -36,11 +37,12 @@
|
|||
* const snapshotId = await sessionSnapshot(sessionId, publicKey, secretKey, 'my-snapshot');
|
||||
* const snapshots = await listSnapshots(publicKey, secretKey);
|
||||
*
|
||||
* Authentication Priority (4-tier):
|
||||
* Authentication Priority (5-tier):
|
||||
* 1. Function arguments (publicKey, secretKey)
|
||||
* 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
|
||||
* 3. ~/.unsandbox/accounts.csv (if in Node.js)
|
||||
* 4. ./accounts.csv (if in Node.js)
|
||||
* 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) [Node.js]
|
||||
* 3. localStorage (unsandboxPublicKey, unsandboxSecretKey) [Browser]
|
||||
* 4. ~/.unsandbox/accounts.csv [Node.js]
|
||||
* 5. ./accounts.csv [Node.js]
|
||||
*
|
||||
* Request Authentication (HMAC-SHA256):
|
||||
* Authorization: Bearer <publicKey>
|
||||
|
|
@ -51,11 +53,32 @@
|
|||
* - Cached in ~/.unsandbox/languages.json (Node.js only)
|
||||
* - TTL: 1 hour
|
||||
* - Updated on successful API calls
|
||||
*
|
||||
* Browser Usage:
|
||||
* - Import as ES module: <script type="module">
|
||||
* - Configure credentials via localStorage:
|
||||
* localStorage.setItem('useUnsandbox', 'true');
|
||||
* localStorage.setItem('unsandboxPublicKey', 'unsb-pk-...');
|
||||
* localStorage.setItem('unsandboxSecretKey', 'unsb-sk-...');
|
||||
* - Or pass credentials directly to functions
|
||||
* - Uses Web Crypto API for HMAC-SHA256 signing
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
// Environment detection for isomorphic support (Node.js + Browser)
|
||||
const IS_BROWSER = typeof window !== 'undefined' && typeof window.document !== 'undefined';
|
||||
const IS_NODE = typeof process !== 'undefined' && process.versions != null && process.versions.node != null;
|
||||
|
||||
// Conditional imports for Node.js (null in browser)
|
||||
let crypto = null;
|
||||
let fs = null;
|
||||
let path = null;
|
||||
|
||||
if (IS_NODE) {
|
||||
// Dynamic imports for Node.js
|
||||
crypto = await import('crypto').then(m => m.default || m);
|
||||
fs = await import('fs').then(m => m.default || m);
|
||||
path = await import('path').then(m => m.default || m);
|
||||
}
|
||||
|
||||
const API_BASE = 'https://api.unsandbox.com';
|
||||
const POLL_DELAYS_MS = [300, 450, 700, 900, 650, 1600, 2000];
|
||||
|
|
@ -76,9 +99,10 @@ class TimeoutError extends Error {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get ~/.unsandbox directory path, creating if necessary.
|
||||
* Get ~/.unsandbox directory path, creating if necessary. [Node.js only]
|
||||
*/
|
||||
function getUnsandboxDir() {
|
||||
if (!IS_NODE) return null;
|
||||
const home = process.env.HOME || process.env.USERPROFILE;
|
||||
if (!home) {
|
||||
throw new Error('Could not determine home directory');
|
||||
|
|
@ -91,9 +115,10 @@ function getUnsandboxDir() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Load credentials from CSV file (public_key,secret_key per line).
|
||||
* Load credentials from CSV file (public_key,secret_key per line). [Node.js only]
|
||||
*/
|
||||
function loadCredentialsFromCsv(csvPath, accountIndex = 0) {
|
||||
if (!IS_NODE || !fs) return null;
|
||||
if (!fs.existsSync(csvPath)) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -122,13 +147,37 @@ function loadCredentialsFromCsv(csvPath, accountIndex = 0) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Resolve credentials from 4-tier priority system.
|
||||
* Load credentials from localStorage. [Browser only]
|
||||
*/
|
||||
function loadCredentialsFromStorage() {
|
||||
if (!IS_BROWSER) return null;
|
||||
|
||||
try {
|
||||
const useUnsandbox = localStorage.getItem('useUnsandbox') === 'true';
|
||||
if (!useUnsandbox) return null;
|
||||
|
||||
const publicKey = localStorage.getItem('unsandboxPublicKey');
|
||||
const secretKey = localStorage.getItem('unsandboxSecretKey');
|
||||
|
||||
if (publicKey && secretKey) {
|
||||
return [publicKey, secretKey];
|
||||
}
|
||||
} catch (e) {
|
||||
// localStorage not available
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve credentials from 5-tier priority system.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Function arguments
|
||||
* 2. Environment variables
|
||||
* 3. ~/.unsandbox/accounts.csv
|
||||
* 4. ./accounts.csv
|
||||
* 2. Environment variables (Node.js)
|
||||
* 3. localStorage (Browser)
|
||||
* 4. ~/.unsandbox/accounts.csv (Node.js)
|
||||
* 5. ./accounts.csv (Node.js)
|
||||
*/
|
||||
function resolveCredentials(publicKey, secretKey, accountIndex) {
|
||||
// Tier 1: Function arguments
|
||||
|
|
@ -136,57 +185,96 @@ function resolveCredentials(publicKey, secretKey, accountIndex) {
|
|||
return [publicKey, secretKey];
|
||||
}
|
||||
|
||||
// Tier 2: Environment variables
|
||||
const envPk = process.env.UNSANDBOX_PUBLIC_KEY;
|
||||
const envSk = process.env.UNSANDBOX_SECRET_KEY;
|
||||
if (envPk && envSk) {
|
||||
return [envPk, envSk];
|
||||
// Tier 2: Environment variables (Node.js only)
|
||||
if (IS_NODE) {
|
||||
const envPk = process.env.UNSANDBOX_PUBLIC_KEY;
|
||||
const envSk = process.env.UNSANDBOX_SECRET_KEY;
|
||||
if (envPk && envSk) {
|
||||
return [envPk, envSk];
|
||||
}
|
||||
}
|
||||
|
||||
// Determine account index
|
||||
if (accountIndex === undefined) {
|
||||
accountIndex = parseInt(process.env.UNSANDBOX_ACCOUNT || '0', 10);
|
||||
// Tier 3: localStorage (Browser only)
|
||||
if (IS_BROWSER) {
|
||||
const storageCreds = loadCredentialsFromStorage();
|
||||
if (storageCreds) {
|
||||
return storageCreds;
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: ~/.unsandbox/accounts.csv
|
||||
try {
|
||||
const unsandboxDir = getUnsandboxDir();
|
||||
const creds = loadCredentialsFromCsv(path.join(unsandboxDir, 'accounts.csv'), accountIndex);
|
||||
// Tier 4 & 5: File-based credentials (Node.js only)
|
||||
if (IS_NODE && fs && path) {
|
||||
// Determine account index
|
||||
if (accountIndex === undefined) {
|
||||
accountIndex = parseInt(process.env.UNSANDBOX_ACCOUNT || '0', 10);
|
||||
}
|
||||
|
||||
// Tier 4: ~/.unsandbox/accounts.csv
|
||||
try {
|
||||
const unsandboxDir = getUnsandboxDir();
|
||||
const creds = loadCredentialsFromCsv(path.join(unsandboxDir, 'accounts.csv'), accountIndex);
|
||||
if (creds) {
|
||||
return creds;
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to next tier
|
||||
}
|
||||
|
||||
// Tier 5: ./accounts.csv
|
||||
const creds = loadCredentialsFromCsv('accounts.csv', accountIndex);
|
||||
if (creds) {
|
||||
return creds;
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to next tier
|
||||
}
|
||||
|
||||
// Tier 4: ./accounts.csv
|
||||
const creds = loadCredentialsFromCsv('accounts.csv', accountIndex);
|
||||
if (creds) {
|
||||
return creds;
|
||||
}
|
||||
const envHint = IS_NODE
|
||||
? ' 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n' +
|
||||
' 3. ~/.unsandbox/accounts.csv\n' +
|
||||
' 4. ./accounts.csv'
|
||||
: ' 2. localStorage (enable "Use unsandbox.com API keys" in settings)';
|
||||
|
||||
throw new CredentialsError(
|
||||
'No credentials found. Please provide via:\n' +
|
||||
' 1. Function arguments (publicKey, secretKey)\n' +
|
||||
' 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n' +
|
||||
' 3. ~/.unsandbox/accounts.csv\n' +
|
||||
' 4. ./accounts.csv'
|
||||
envHint
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a request using HMAC-SHA256.
|
||||
* Isomorphic: uses Node.js crypto or Web Crypto API.
|
||||
*
|
||||
* Message format: "timestamp:METHOD:path:body"
|
||||
* Returns: 64-character hex string
|
||||
*/
|
||||
function signRequest(secretKey, timestamp, method, urlPath, body) {
|
||||
async function signRequest(secretKey, timestamp, method, urlPath, body) {
|
||||
const bodyStr = body || '';
|
||||
const message = `${timestamp}:${method}:${urlPath}:${bodyStr}`;
|
||||
return crypto
|
||||
.createHmac('sha256', secretKey)
|
||||
.update(message)
|
||||
.digest('hex');
|
||||
|
||||
if (IS_NODE && crypto) {
|
||||
// Node.js: synchronous HMAC
|
||||
return crypto
|
||||
.createHmac('sha256', secretKey)
|
||||
.update(message)
|
||||
.digest('hex');
|
||||
} else {
|
||||
// Browser: Web Crypto API (async)
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(secretKey);
|
||||
const messageData = encoder.encode(message);
|
||||
|
||||
const cryptoKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = await window.crypto.subtle.sign('HMAC', cryptoKey, messageData);
|
||||
const hashArray = Array.from(new Uint8Array(signature));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -207,7 +295,7 @@ async function makeRequest(method, urlPath, publicKey, secretKey, data) {
|
|||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const body = data ? JSON.stringify(data) : '';
|
||||
|
||||
const signature = signRequest(secretKey, timestamp, method, urlPath, body || null);
|
||||
const signature = await signRequest(secretKey, timestamp, method, urlPath, body || null);
|
||||
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${publicKey}`,
|
||||
|
|
@ -239,19 +327,23 @@ async function makeRequest(method, urlPath, publicKey, secretKey, data) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get path to languages cache file.
|
||||
* Get path to languages cache file. [Node.js only]
|
||||
*/
|
||||
function getLanguagesCachePath() {
|
||||
return path.join(getUnsandboxDir(), 'languages.json');
|
||||
if (!IS_NODE) return null;
|
||||
const dir = getUnsandboxDir();
|
||||
if (!dir) return null;
|
||||
return path.join(dir, 'languages.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load languages from cache if valid (< 1 hour old).
|
||||
* Load languages from cache if valid (< 1 hour old). [Node.js only]
|
||||
*/
|
||||
function loadLanguagesCache() {
|
||||
if (!IS_NODE || !fs) return null;
|
||||
try {
|
||||
const cachePath = getLanguagesCachePath();
|
||||
if (!fs.existsSync(cachePath)) {
|
||||
if (!cachePath || !fs.existsSync(cachePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -269,11 +361,13 @@ function loadLanguagesCache() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Save languages to cache.
|
||||
* Save languages to cache. [Node.js only]
|
||||
*/
|
||||
function saveLanguagesCache(languages) {
|
||||
if (!IS_NODE || !fs) return;
|
||||
try {
|
||||
const cachePath = getLanguagesCachePath();
|
||||
if (!cachePath) return;
|
||||
const data = {
|
||||
languages,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
|
|
@ -2135,17 +2229,19 @@ async function cliMain() {
|
|||
}
|
||||
}
|
||||
|
||||
// CLI entry point - detect if running as main module (ESM)
|
||||
// CLI entry point - detect if running as main module (ESM) [Node.js only]
|
||||
// In ESM, we use import.meta.url to check if this is the main module
|
||||
const isMain = process.argv[1] && (
|
||||
process.argv[1].endsWith('/un_async.js') ||
|
||||
process.argv[1].endsWith('\\un_async.js') ||
|
||||
import.meta.url === `file://${process.argv[1]}`
|
||||
);
|
||||
if (IS_NODE) {
|
||||
const isMain = process.argv[1] && (
|
||||
process.argv[1].endsWith('/un_async.js') ||
|
||||
process.argv[1].endsWith('\\un_async.js') ||
|
||||
import.meta.url === `file://${process.argv[1]}`
|
||||
);
|
||||
|
||||
if (isMain) {
|
||||
cliMain().catch((err) => {
|
||||
console.error('Error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
if (isMain) {
|
||||
cliMain().catch((err) => {
|
||||
console.error('Error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue