mirror of
https://github.com/DeBrosOfficial/network-ts-sdk.git
synced 2026-01-30 05:03:02 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15b3bb382e | ||
|
|
26c0169aaf | ||
|
|
58097e3ff8 | ||
|
|
25303e7913 |
2
LICENSE
2
LICENSE
@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) [year] [fullname]
|
||||
Copyright (c) 2026 DeBrosOfficial
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@ -367,6 +367,7 @@ interface ClientConfig {
|
||||
timeout?: number; // Request timeout in ms (default: 30000)
|
||||
maxRetries?: number; // Max retry attempts (default: 3)
|
||||
retryDelayMs?: number; // Delay between retries (default: 1000)
|
||||
debug?: boolean; // Enable debug logging with full SQL queries (default: false)
|
||||
storage?: StorageAdapter; // For persisting JWT/API key (default: MemoryStorage)
|
||||
wsConfig?: Partial<WSClientConfig>; // WebSocket configuration
|
||||
fetch?: typeof fetch; // Custom fetch implementation
|
||||
|
||||
100
examples/basic-usage.ts
Normal file
100
examples/basic-usage.ts
Normal file
@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Basic Usage Example
|
||||
*
|
||||
* This example demonstrates the fundamental usage of the DeBros Network SDK.
|
||||
* It covers client initialization, database operations, pub/sub, and caching.
|
||||
*/
|
||||
|
||||
import { createClient } from '../src/index';
|
||||
|
||||
async function main() {
|
||||
// 1. Create client
|
||||
const client = createClient({
|
||||
baseURL: 'http://localhost:6001',
|
||||
apiKey: 'ak_your_key:default',
|
||||
debug: true, // Enable debug logging
|
||||
});
|
||||
|
||||
console.log('✓ Client created');
|
||||
|
||||
// 2. Database operations
|
||||
console.log('\n--- Database Operations ---');
|
||||
|
||||
// Create table
|
||||
await client.db.createTable(
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
created_at INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
)`
|
||||
);
|
||||
console.log('✓ Table created');
|
||||
|
||||
// Insert data
|
||||
const result = await client.db.exec(
|
||||
'INSERT INTO users (name, email) VALUES (?, ?)',
|
||||
['Alice Johnson', 'alice@example.com']
|
||||
);
|
||||
console.log(`✓ Inserted user with ID: ${result.last_insert_id}`);
|
||||
|
||||
// Query data
|
||||
const users = await client.db.query(
|
||||
'SELECT * FROM users WHERE email = ?',
|
||||
['alice@example.com']
|
||||
);
|
||||
console.log('✓ Found users:', users);
|
||||
|
||||
// 3. Pub/Sub messaging
|
||||
console.log('\n--- Pub/Sub Messaging ---');
|
||||
|
||||
const subscription = await client.pubsub.subscribe('demo-topic', {
|
||||
onMessage: (msg) => {
|
||||
console.log(`✓ Received message: "${msg.data}" at ${new Date(msg.timestamp).toISOString()}`);
|
||||
},
|
||||
onError: (err) => console.error('Subscription error:', err),
|
||||
});
|
||||
console.log('✓ Subscribed to demo-topic');
|
||||
|
||||
// Publish a message
|
||||
await client.pubsub.publish('demo-topic', 'Hello from the SDK!');
|
||||
console.log('✓ Published message');
|
||||
|
||||
// Wait a bit for message delivery
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Close subscription
|
||||
subscription.close();
|
||||
console.log('✓ Subscription closed');
|
||||
|
||||
// 4. Cache operations
|
||||
console.log('\n--- Cache Operations ---');
|
||||
|
||||
// Put value with 1-hour TTL
|
||||
await client.cache.put('default', 'user:alice', {
|
||||
id: result.last_insert_id,
|
||||
name: 'Alice Johnson',
|
||||
email: 'alice@example.com',
|
||||
}, '1h');
|
||||
console.log('✓ Cached user data');
|
||||
|
||||
// Get value
|
||||
const cached = await client.cache.get('default', 'user:alice');
|
||||
if (cached) {
|
||||
console.log('✓ Retrieved from cache:', cached.value);
|
||||
}
|
||||
|
||||
// 5. Network health check
|
||||
console.log('\n--- Network Operations ---');
|
||||
|
||||
const healthy = await client.network.health();
|
||||
console.log(`✓ Gateway health: ${healthy ? 'OK' : 'FAIL'}`);
|
||||
|
||||
const status = await client.network.status();
|
||||
console.log(`✓ Network status: ${status.peer_count} peers connected`);
|
||||
|
||||
console.log('\n--- Example completed successfully ---');
|
||||
}
|
||||
|
||||
// Run example
|
||||
main().catch(console.error);
|
||||
170
examples/database-crud.ts
Normal file
170
examples/database-crud.ts
Normal file
@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Database CRUD Operations Example
|
||||
*
|
||||
* Demonstrates comprehensive database operations including:
|
||||
* - Table creation and schema management
|
||||
* - Insert, Update, Delete operations
|
||||
* - QueryBuilder fluent API
|
||||
* - Repository pattern (ORM-style)
|
||||
* - Transactions
|
||||
*/
|
||||
|
||||
import { createClient } from '../src/index';
|
||||
|
||||
interface User {
|
||||
id?: number;
|
||||
name: string;
|
||||
email: string;
|
||||
age: number;
|
||||
active?: number;
|
||||
created_at?: number;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const client = createClient({
|
||||
baseURL: 'http://localhost:6001',
|
||||
apiKey: 'ak_your_key:default',
|
||||
});
|
||||
|
||||
// 1. Create table
|
||||
console.log('Creating users table...');
|
||||
await client.db.createTable(
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
age INTEGER,
|
||||
active INTEGER DEFAULT 1,
|
||||
created_at INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
)`
|
||||
);
|
||||
|
||||
// 2. Raw SQL INSERT
|
||||
console.log('\n--- Raw SQL Operations ---');
|
||||
const insertResult = await client.db.exec(
|
||||
'INSERT INTO users (name, email, age) VALUES (?, ?, ?)',
|
||||
['Bob Smith', 'bob@example.com', 30]
|
||||
);
|
||||
console.log('Inserted ID:', insertResult.last_insert_id);
|
||||
|
||||
// 3. Raw SQL UPDATE
|
||||
await client.db.exec(
|
||||
'UPDATE users SET age = ? WHERE id = ?',
|
||||
[31, insertResult.last_insert_id]
|
||||
);
|
||||
console.log('Updated user age');
|
||||
|
||||
// 4. Raw SQL SELECT
|
||||
const users = await client.db.query<User>(
|
||||
'SELECT * FROM users WHERE email = ?',
|
||||
['bob@example.com']
|
||||
);
|
||||
console.log('Found users:', users);
|
||||
|
||||
// 5. QueryBuilder
|
||||
console.log('\n--- QueryBuilder Operations ---');
|
||||
|
||||
// Insert multiple users for querying
|
||||
await client.db.exec("INSERT INTO users (name, email, age) VALUES (?, ?, ?)", ["Charlie", "charlie@example.com", 25]);
|
||||
await client.db.exec("INSERT INTO users (name, email, age) VALUES (?, ?, ?)", ["Diana", "diana@example.com", 35]);
|
||||
await client.db.exec("INSERT INTO users (name, email, age) VALUES (?, ?, ?)", ["Eve", "eve@example.com", 28]);
|
||||
|
||||
// Complex query with QueryBuilder
|
||||
const activeUsers = await client.db
|
||||
.createQueryBuilder('users')
|
||||
.select('id', 'name', 'email', 'age')
|
||||
.where('active = ?', [1])
|
||||
.andWhere('age > ?', [25])
|
||||
.orderBy('age DESC')
|
||||
.limit(10)
|
||||
.getMany<User>();
|
||||
|
||||
console.log('Active users over 25:', activeUsers);
|
||||
|
||||
// Get single user
|
||||
const singleUser = await client.db
|
||||
.createQueryBuilder('users')
|
||||
.where('email = ?', ['charlie@example.com'])
|
||||
.getOne<User>();
|
||||
|
||||
console.log('Single user:', singleUser);
|
||||
|
||||
// Count users
|
||||
const count = await client.db
|
||||
.createQueryBuilder('users')
|
||||
.where('age > ?', [25])
|
||||
.count();
|
||||
|
||||
console.log('Users over 25:', count);
|
||||
|
||||
// 6. Repository Pattern (ORM)
|
||||
console.log('\n--- Repository Pattern ---');
|
||||
|
||||
const userRepo = client.db.repository<User>('users');
|
||||
|
||||
// Find all
|
||||
const allUsers = await userRepo.find({});
|
||||
console.log('All users:', allUsers.length);
|
||||
|
||||
// Find with criteria
|
||||
const youngUsers = await userRepo.find({ age: 25 });
|
||||
console.log('Users aged 25:', youngUsers);
|
||||
|
||||
// Find one
|
||||
const diana = await userRepo.findOne({ email: 'diana@example.com' });
|
||||
console.log('Found Diana:', diana);
|
||||
|
||||
// Save (insert new)
|
||||
const newUser: User = {
|
||||
name: 'Frank',
|
||||
email: 'frank@example.com',
|
||||
age: 40,
|
||||
};
|
||||
await userRepo.save(newUser);
|
||||
console.log('Saved new user:', newUser);
|
||||
|
||||
// Save (update existing)
|
||||
if (diana) {
|
||||
diana.age = 36;
|
||||
await userRepo.save(diana);
|
||||
console.log('Updated Diana:', diana);
|
||||
}
|
||||
|
||||
// Remove
|
||||
if (newUser.id) {
|
||||
await userRepo.remove(newUser);
|
||||
console.log('Deleted Frank');
|
||||
}
|
||||
|
||||
// 7. Transactions
|
||||
console.log('\n--- Transaction Operations ---');
|
||||
|
||||
const txResults = await client.db.transaction([
|
||||
{
|
||||
kind: 'exec',
|
||||
sql: 'INSERT INTO users (name, email, age) VALUES (?, ?, ?)',
|
||||
args: ['Grace', 'grace@example.com', 27],
|
||||
},
|
||||
{
|
||||
kind: 'exec',
|
||||
sql: 'UPDATE users SET active = ? WHERE age < ?',
|
||||
args: [0, 26],
|
||||
},
|
||||
{
|
||||
kind: 'query',
|
||||
sql: 'SELECT COUNT(*) as count FROM users WHERE active = ?',
|
||||
args: [1],
|
||||
},
|
||||
]);
|
||||
|
||||
console.log('Transaction results:', txResults);
|
||||
|
||||
// 8. Get schema
|
||||
console.log('\n--- Schema Information ---');
|
||||
const schema = await client.db.getSchema();
|
||||
console.log('Database schema:', schema);
|
||||
|
||||
console.log('\n--- CRUD operations completed successfully ---');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
140
examples/pubsub-chat.ts
Normal file
140
examples/pubsub-chat.ts
Normal file
@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Pub/Sub Chat Example
|
||||
*
|
||||
* Demonstrates a simple chat application using pub/sub with presence tracking.
|
||||
* Multiple clients can join a room, send messages, and see who's online.
|
||||
*/
|
||||
|
||||
import { createClient } from '../src/index';
|
||||
import type { PresenceMember } from '../src/index';
|
||||
|
||||
interface ChatMessage {
|
||||
user: string;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
async function createChatClient(userName: string, roomName: string) {
|
||||
const client = createClient({
|
||||
baseURL: 'http://localhost:6001',
|
||||
apiKey: 'ak_your_key:default',
|
||||
});
|
||||
|
||||
console.log(`[${userName}] Joining room: ${roomName}...`);
|
||||
|
||||
// Subscribe to chat room with presence
|
||||
const subscription = await client.pubsub.subscribe(roomName, {
|
||||
onMessage: (msg) => {
|
||||
try {
|
||||
const chatMsg: ChatMessage = JSON.parse(msg.data);
|
||||
const time = new Date(chatMsg.timestamp).toLocaleTimeString();
|
||||
console.log(`[${time}] ${chatMsg.user}: ${chatMsg.text}`);
|
||||
} catch {
|
||||
console.log(`[${userName}] Received: ${msg.data}`);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(`[${userName}] Error:`, err.message);
|
||||
},
|
||||
onClose: () => {
|
||||
console.log(`[${userName}] Disconnected from ${roomName}`);
|
||||
},
|
||||
presence: {
|
||||
enabled: true,
|
||||
memberId: userName,
|
||||
meta: {
|
||||
displayName: userName,
|
||||
joinedAt: Date.now(),
|
||||
},
|
||||
onJoin: (member: PresenceMember) => {
|
||||
console.log(`[${userName}] 👋 ${member.memberId} joined the room`);
|
||||
if (member.meta) {
|
||||
console.log(`[${userName}] Display name: ${member.meta.displayName}`);
|
||||
}
|
||||
},
|
||||
onLeave: (member: PresenceMember) => {
|
||||
console.log(`[${userName}] 👋 ${member.memberId} left the room`);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[${userName}] ✓ Joined ${roomName}`);
|
||||
|
||||
// Send a join message
|
||||
await sendMessage(client, roomName, userName, 'Hello everyone!');
|
||||
|
||||
// Helper to send messages
|
||||
async function sendMessage(client: any, room: string, user: string, text: string) {
|
||||
const chatMsg: ChatMessage = {
|
||||
user,
|
||||
text,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
await client.pubsub.publish(room, JSON.stringify(chatMsg));
|
||||
}
|
||||
|
||||
// Get current presence
|
||||
if (subscription.hasPresence()) {
|
||||
const members = await subscription.getPresence();
|
||||
console.log(`[${userName}] Current members in room (${members.length}):`);
|
||||
members.forEach(m => {
|
||||
console.log(`[${userName}] - ${m.memberId} (joined at ${new Date(m.joinedAt).toLocaleTimeString()})`);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
client,
|
||||
subscription,
|
||||
sendMessage: (text: string) => sendMessage(client, roomName, userName, text),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const roomName = 'chat:lobby';
|
||||
|
||||
// Create first user
|
||||
const alice = await createChatClient('Alice', roomName);
|
||||
|
||||
// Wait a bit
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Create second user
|
||||
const bob = await createChatClient('Bob', roomName);
|
||||
|
||||
// Wait a bit
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Send some messages
|
||||
await alice.sendMessage('Hey Bob! How are you?');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
await bob.sendMessage('Hi Alice! I\'m doing great, thanks!');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
await alice.sendMessage('That\'s awesome! Want to grab coffee later?');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
await bob.sendMessage('Sure! See you at 3pm?');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
await alice.sendMessage('Perfect! See you then! 👋');
|
||||
|
||||
// Wait to receive all messages
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Get final presence count
|
||||
const presence = await alice.client.pubsub.getPresence(roomName);
|
||||
console.log(`\nFinal presence count: ${presence.count} members`);
|
||||
|
||||
// Leave room
|
||||
console.log('\nClosing connections...');
|
||||
alice.subscription.close();
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
bob.subscription.close();
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
console.log('\n--- Chat example completed ---');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
29
package.json
29
package.json
@ -1,15 +1,36 @@
|
||||
{
|
||||
"name": "@debros/network-ts-sdk",
|
||||
"version": "0.4.1",
|
||||
"description": "TypeScript SDK for DeBros Network Gateway",
|
||||
"version": "0.6.0",
|
||||
"description": "TypeScript SDK for DeBros Network Gateway - Database, PubSub, Cache, Storage, and more",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"author": "DeBrosOfficial",
|
||||
"keywords": [
|
||||
"debros",
|
||||
"network",
|
||||
"sdk",
|
||||
"typescript",
|
||||
"database",
|
||||
"rqlite",
|
||||
"pubsub",
|
||||
"websocket",
|
||||
"cache",
|
||||
"olric",
|
||||
"ipfs",
|
||||
"storage",
|
||||
"wasm",
|
||||
"serverless",
|
||||
"distributed",
|
||||
"gateway"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/DeBrosOfficial/network-ts-sdk/tree/v0.0.1"
|
||||
"url": "https://github.com/DeBrosOfficial/network-ts-sdk"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/DeBrosOfficial/network-ts-sdk/issues"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
@ -38,9 +59,11 @@
|
||||
"@types/node": "^20.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@vitest/coverage-v8": "^1.0.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^8.0.0",
|
||||
"tsup": "^8.0.0",
|
||||
"typedoc": "^0.25.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vitest": "^1.0.0"
|
||||
},
|
||||
|
||||
206
pnpm-lock.yaml
generated
206
pnpm-lock.yaml
generated
@ -21,6 +21,9 @@ importers:
|
||||
'@typescript-eslint/parser':
|
||||
specifier: ^6.0.0
|
||||
version: 6.21.0(eslint@8.57.1)(typescript@5.9.3)
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^1.0.0
|
||||
version: 1.6.1(vitest@1.6.1(@types/node@20.19.23))
|
||||
dotenv:
|
||||
specifier: ^17.2.3
|
||||
version: 17.2.3
|
||||
@ -30,6 +33,9 @@ importers:
|
||||
tsup:
|
||||
specifier: ^8.0.0
|
||||
version: 8.5.0(postcss@8.5.6)(typescript@5.9.3)
|
||||
typedoc:
|
||||
specifier: ^0.25.0
|
||||
version: 0.25.13(typescript@5.9.3)
|
||||
typescript:
|
||||
specifier: ^5.3.0
|
||||
version: 5.9.3
|
||||
@ -39,6 +45,30 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@babel/helper-string-parser@7.27.1':
|
||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5':
|
||||
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/parser@7.28.6':
|
||||
resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/types@7.28.6':
|
||||
resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@bcoe/v8-coverage@0.2.3':
|
||||
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.21.5':
|
||||
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
||||
engines: {node: '>=12'}
|
||||
@ -368,6 +398,10 @@ packages:
|
||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@istanbuljs/schema@0.1.3':
|
||||
resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@jest/schemas@29.6.3':
|
||||
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
@ -587,6 +621,11 @@ packages:
|
||||
'@ungap/structured-clone@1.3.0':
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
|
||||
'@vitest/coverage-v8@1.6.1':
|
||||
resolution: {integrity: sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==}
|
||||
peerDependencies:
|
||||
vitest: 1.6.1
|
||||
|
||||
'@vitest/expect@1.6.1':
|
||||
resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==}
|
||||
|
||||
@ -627,6 +666,9 @@ packages:
|
||||
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ansi-sequence-parser@1.1.3:
|
||||
resolution: {integrity: sha512-+fksAx9eG3Ab6LDnLs3ZqZa8KVJ/jYnX+D4Qe1azX+LFGFAXqynCQLOdLpNYN/l9e7l6hMWwZbrnctqr6eSQSw==}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
@ -912,6 +954,9 @@ packages:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
human-signals@5.0.0:
|
||||
resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
|
||||
engines: {node: '>=16.17.0'}
|
||||
@ -967,6 +1012,22 @@ packages:
|
||||
peerDependencies:
|
||||
ws: '*'
|
||||
|
||||
istanbul-lib-coverage@3.2.2:
|
||||
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
istanbul-lib-source-maps@5.0.6:
|
||||
resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
jackspeak@3.4.3:
|
||||
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
|
||||
|
||||
@ -990,6 +1051,9 @@ packages:
|
||||
json-stable-stringify-without-jsonify@1.0.1:
|
||||
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
||||
|
||||
jsonc-parser@3.3.1:
|
||||
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@ -1028,9 +1092,24 @@ packages:
|
||||
lru-cache@10.4.3:
|
||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||
|
||||
lunr@2.3.9:
|
||||
resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==}
|
||||
|
||||
magic-string@0.30.19:
|
||||
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
|
||||
|
||||
magicast@0.3.5:
|
||||
resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
|
||||
|
||||
make-dir@4.0.0:
|
||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
marked@4.3.0:
|
||||
resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==}
|
||||
engines: {node: '>= 12'}
|
||||
hasBin: true
|
||||
|
||||
merge-stream@2.0.0:
|
||||
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
|
||||
|
||||
@ -1249,6 +1328,9 @@ packages:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shiki@0.14.7:
|
||||
resolution: {integrity: sha512-dNPAPrxSc87ua2sKJ3H5dQ/6ZaY8RNnaAqK+t0eG7p0Soi2ydiqbGOTaZCqaYvA/uZYfS1LJnemt3Q+mSfcPCg==}
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
@ -1311,6 +1393,10 @@ packages:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
test-exclude@6.0.0:
|
||||
resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
text-table@0.2.0:
|
||||
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
|
||||
|
||||
@ -1390,6 +1476,13 @@ packages:
|
||||
resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
typedoc@0.25.13:
|
||||
resolution: {integrity: sha512-pQqiwiJ+Z4pigfOnnysObszLiU3mVLWAExSPf+Mu06G/qsc3wzbuM56SZQvONhHLncLUhYzOVkjFFpFfL5AzhQ==}
|
||||
engines: {node: '>= 16'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: 4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
@ -1465,6 +1558,12 @@ packages:
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
vscode-oniguruma@1.7.0:
|
||||
resolution: {integrity: sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==}
|
||||
|
||||
vscode-textmate@8.0.0:
|
||||
resolution: {integrity: sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==}
|
||||
|
||||
webidl-conversions@4.0.2:
|
||||
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
|
||||
|
||||
@ -1518,6 +1617,26 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@babel/helper-string-parser@7.27.1': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5': {}
|
||||
|
||||
'@babel/parser@7.28.6':
|
||||
dependencies:
|
||||
'@babel/types': 7.28.6
|
||||
|
||||
'@babel/types@7.28.6':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@bcoe/v8-coverage@0.2.3': {}
|
||||
|
||||
'@esbuild/aix-ppc64@0.21.5':
|
||||
optional: true
|
||||
|
||||
@ -1709,6 +1828,8 @@ snapshots:
|
||||
wrap-ansi: 8.1.0
|
||||
wrap-ansi-cjs: wrap-ansi@7.0.0
|
||||
|
||||
'@istanbuljs/schema@0.1.3': {}
|
||||
|
||||
'@jest/schemas@29.6.3':
|
||||
dependencies:
|
||||
'@sinclair/typebox': 0.27.8
|
||||
@ -1908,6 +2029,25 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@vitest/coverage-v8@1.6.1(vitest@1.6.1(@types/node@20.19.23))':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
'@bcoe/v8-coverage': 0.2.3
|
||||
debug: 4.4.3
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-lib-source-maps: 5.0.6
|
||||
istanbul-reports: 3.2.0
|
||||
magic-string: 0.30.19
|
||||
magicast: 0.3.5
|
||||
picocolors: 1.1.1
|
||||
std-env: 3.10.0
|
||||
strip-literal: 2.1.1
|
||||
test-exclude: 6.0.0
|
||||
vitest: 1.6.1(@types/node@20.19.23)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitest/expect@1.6.1':
|
||||
dependencies:
|
||||
'@vitest/spy': 1.6.1
|
||||
@ -1958,6 +2098,8 @@ snapshots:
|
||||
|
||||
ansi-regex@6.2.2: {}
|
||||
|
||||
ansi-sequence-parser@1.1.3: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
@ -2316,6 +2458,8 @@ snapshots:
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
human-signals@5.0.0: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
@ -2354,6 +2498,27 @@ snapshots:
|
||||
dependencies:
|
||||
ws: 8.18.3
|
||||
|
||||
istanbul-lib-coverage@3.2.2: {}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
dependencies:
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
make-dir: 4.0.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
istanbul-lib-source-maps@5.0.6:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
debug: 4.4.3
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
dependencies:
|
||||
html-escaper: 2.0.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
|
||||
jackspeak@3.4.3:
|
||||
dependencies:
|
||||
'@isaacs/cliui': 8.0.2
|
||||
@ -2374,6 +2539,8 @@ snapshots:
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||
|
||||
jsonc-parser@3.3.1: {}
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
@ -2408,10 +2575,24 @@ snapshots:
|
||||
|
||||
lru-cache@10.4.3: {}
|
||||
|
||||
lunr@2.3.9: {}
|
||||
|
||||
magic-string@0.30.19:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
magicast@0.3.5:
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.6
|
||||
'@babel/types': 7.28.6
|
||||
source-map-js: 1.2.1
|
||||
|
||||
make-dir@4.0.0:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
|
||||
marked@4.3.0: {}
|
||||
|
||||
merge-stream@2.0.0: {}
|
||||
|
||||
merge2@1.4.1: {}
|
||||
@ -2610,6 +2791,13 @@ snapshots:
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
shiki@0.14.7:
|
||||
dependencies:
|
||||
ansi-sequence-parser: 1.1.3
|
||||
jsonc-parser: 3.3.1
|
||||
vscode-oniguruma: 1.7.0
|
||||
vscode-textmate: 8.0.0
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
@ -2668,6 +2856,12 @@ snapshots:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
test-exclude@6.0.0:
|
||||
dependencies:
|
||||
'@istanbuljs/schema': 0.1.3
|
||||
glob: 7.2.3
|
||||
minimatch: 3.1.2
|
||||
|
||||
text-table@0.2.0: {}
|
||||
|
||||
thenify-all@1.6.0:
|
||||
@ -2743,6 +2937,14 @@ snapshots:
|
||||
|
||||
type-fest@0.20.2: {}
|
||||
|
||||
typedoc@0.25.13(typescript@5.9.3):
|
||||
dependencies:
|
||||
lunr: 2.3.9
|
||||
marked: 4.3.0
|
||||
minimatch: 9.0.5
|
||||
shiki: 0.14.7
|
||||
typescript: 5.9.3
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
ufo@1.6.1: {}
|
||||
@ -2814,6 +3016,10 @@ snapshots:
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
vscode-oniguruma@1.7.0: {}
|
||||
|
||||
vscode-textmate@8.0.0: {}
|
||||
|
||||
webidl-conversions@4.0.2: {}
|
||||
|
||||
whatwg-url@7.1.0:
|
||||
|
||||
3
src/auth/index.ts
Normal file
3
src/auth/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { AuthClient } from "./client";
|
||||
export type { AuthConfig, WhoAmI, StorageAdapter } from "./types";
|
||||
export { MemoryStorage, LocalStorageAdapter } from "./types";
|
||||
14
src/cache/index.ts
vendored
Normal file
14
src/cache/index.ts
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
export { CacheClient } from "./client";
|
||||
export type {
|
||||
CacheGetRequest,
|
||||
CacheGetResponse,
|
||||
CachePutRequest,
|
||||
CachePutResponse,
|
||||
CacheDeleteRequest,
|
||||
CacheDeleteResponse,
|
||||
CacheMultiGetRequest,
|
||||
CacheMultiGetResponse,
|
||||
CacheScanRequest,
|
||||
CacheScanResponse,
|
||||
CacheHealthResponse,
|
||||
} from "./client";
|
||||
262
src/core/http.ts
262
src/core/http.ts
@ -1,12 +1,39 @@
|
||||
import { SDKError } from "../errors";
|
||||
|
||||
/**
|
||||
* Context provided to the onNetworkError callback
|
||||
*/
|
||||
export interface NetworkErrorContext {
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "WS";
|
||||
path: string;
|
||||
isRetry: boolean;
|
||||
attempt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked when a network error occurs.
|
||||
* Use this to trigger gateway failover or other error handling.
|
||||
*/
|
||||
export type NetworkErrorCallback = (
|
||||
error: SDKError,
|
||||
context: NetworkErrorContext
|
||||
) => void;
|
||||
|
||||
export interface HttpClientConfig {
|
||||
baseURL: string | string[];
|
||||
baseURL: string;
|
||||
timeout?: number;
|
||||
maxRetries?: number;
|
||||
retryDelayMs?: number;
|
||||
fetch?: typeof fetch;
|
||||
gatewayHealthCheckCooldownMs?: number;
|
||||
/**
|
||||
* Enable debug logging (includes full SQL queries and args). Default: false
|
||||
*/
|
||||
debug?: boolean;
|
||||
/**
|
||||
* Callback invoked on network errors (after all retries exhausted).
|
||||
* Use this to trigger gateway failover at the application layer.
|
||||
*/
|
||||
onNetworkError?: NetworkErrorCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -32,38 +59,33 @@ function createFetchWithTLSConfig(): typeof fetch {
|
||||
return globalThis.fetch;
|
||||
}
|
||||
|
||||
interface GatewayHealth {
|
||||
url: string;
|
||||
unhealthyUntil: number | null; // Timestamp when gateway becomes healthy again
|
||||
}
|
||||
|
||||
export class HttpClient {
|
||||
private baseURLs: string[];
|
||||
private currentURLIndex: number = 0;
|
||||
private baseURL: string;
|
||||
private timeout: number;
|
||||
private maxRetries: number;
|
||||
private retryDelayMs: number;
|
||||
private fetch: typeof fetch;
|
||||
private apiKey?: string;
|
||||
private jwt?: string;
|
||||
private gatewayHealthCheckCooldownMs: number;
|
||||
private gatewayHealth: Map<string, GatewayHealth>;
|
||||
private debug: boolean;
|
||||
private onNetworkError?: NetworkErrorCallback;
|
||||
|
||||
constructor(config: HttpClientConfig) {
|
||||
this.baseURLs = (Array.isArray(config.baseURL) ? config.baseURL : [config.baseURL])
|
||||
.map(url => url.replace(/\/$/, ""));
|
||||
this.baseURL = config.baseURL.replace(/\/$/, "");
|
||||
this.timeout = config.timeout ?? 60000;
|
||||
this.maxRetries = config.maxRetries ?? 3;
|
||||
this.retryDelayMs = config.retryDelayMs ?? 1000;
|
||||
this.gatewayHealthCheckCooldownMs = config.gatewayHealthCheckCooldownMs ?? 600000; // Default 10 minutes
|
||||
// Use provided fetch or create one with proper TLS configuration for staging certificates
|
||||
this.fetch = config.fetch ?? createFetchWithTLSConfig();
|
||||
this.debug = config.debug ?? false;
|
||||
this.onNetworkError = config.onNetworkError;
|
||||
}
|
||||
|
||||
// Initialize gateway health tracking
|
||||
this.gatewayHealth = new Map();
|
||||
this.baseURLs.forEach(url => {
|
||||
this.gatewayHealth.set(url, { url, unhealthyUntil: null });
|
||||
});
|
||||
/**
|
||||
* Set the network error callback
|
||||
*/
|
||||
setOnNetworkError(callback: NetworkErrorCallback | undefined): void {
|
||||
this.onNetworkError = callback;
|
||||
}
|
||||
|
||||
setApiKey(apiKey?: string) {
|
||||
@ -139,89 +161,10 @@ export class HttpClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current base URL (for single gateway or current gateway in multi-gateway setup)
|
||||
* Get the base URL
|
||||
*/
|
||||
private getCurrentBaseURL(): string {
|
||||
return this.baseURLs[this.currentURLIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all base URLs (for WebSocket or other purposes that need all gateways)
|
||||
*/
|
||||
getBaseURLs(): string[] {
|
||||
return [...this.baseURLs];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a gateway is healthy (not in cooldown period)
|
||||
*/
|
||||
private isGatewayHealthy(url: string): boolean {
|
||||
const health = this.gatewayHealth.get(url);
|
||||
if (!health || health.unhealthyUntil === null) {
|
||||
return true;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (now >= health.unhealthyUntil) {
|
||||
// Cooldown period expired, mark as healthy again
|
||||
health.unhealthyUntil = null;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a gateway as unhealthy for the cooldown period
|
||||
*/
|
||||
private markGatewayUnhealthy(url: string): void {
|
||||
const health = this.gatewayHealth.get(url);
|
||||
if (health) {
|
||||
health.unhealthyUntil = Date.now() + this.gatewayHealthCheckCooldownMs;
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn(
|
||||
`[HttpClient] Gateway marked unhealthy for ${this.gatewayHealthCheckCooldownMs / 1000}s:`,
|
||||
url
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try the next healthy gateway in the list
|
||||
* Returns the index of the next healthy gateway, or -1 if none available
|
||||
*/
|
||||
private findNextHealthyGateway(): number {
|
||||
if (this.baseURLs.length <= 1) {
|
||||
return -1; // No other gateways to try
|
||||
}
|
||||
|
||||
const startIndex = this.currentURLIndex;
|
||||
let attempts = 0;
|
||||
|
||||
// Try each gateway once (excluding current)
|
||||
while (attempts < this.baseURLs.length - 1) {
|
||||
const nextIndex = (startIndex + attempts + 1) % this.baseURLs.length;
|
||||
const nextUrl = this.baseURLs[nextIndex];
|
||||
|
||||
if (this.isGatewayHealthy(nextUrl)) {
|
||||
return nextIndex;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
}
|
||||
|
||||
return -1; // No healthy gateways found
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to the next healthy gateway
|
||||
*/
|
||||
private moveToNextGateway(): boolean {
|
||||
const nextIndex = this.findNextHealthyGateway();
|
||||
if (nextIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
this.currentURLIndex = nextIndex;
|
||||
return true;
|
||||
getBaseURL(): string {
|
||||
return this.baseURL;
|
||||
}
|
||||
|
||||
async request<T = any>(
|
||||
@ -235,7 +178,7 @@ export class HttpClient {
|
||||
} = {}
|
||||
): Promise<T> {
|
||||
const startTime = performance.now(); // Track request start time
|
||||
const url = new URL(this.getCurrentBaseURL() + path);
|
||||
const url = new URL(this.baseURL + path);
|
||||
if (options.query) {
|
||||
Object.entries(options.query).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, String(value));
|
||||
@ -319,7 +262,7 @@ export class HttpClient {
|
||||
const logMessage = `[HttpClient] ${method} ${path} completed in ${duration.toFixed(
|
||||
2
|
||||
)}ms`;
|
||||
if (queryDetails) {
|
||||
if (queryDetails && this.debug) {
|
||||
console.log(logMessage);
|
||||
console.log(`[HttpClient] ${queryDetails}`);
|
||||
} else {
|
||||
@ -349,11 +292,32 @@ export class HttpClient {
|
||||
2
|
||||
)}ms:`;
|
||||
console.error(errorMessage, error);
|
||||
if (queryDetails) {
|
||||
if (queryDetails && this.debug) {
|
||||
console.error(`[HttpClient] ${queryDetails}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call the network error callback if configured
|
||||
// This allows the app to trigger gateway failover
|
||||
if (this.onNetworkError) {
|
||||
// Convert native errors (TypeError, AbortError) to SDKError for the callback
|
||||
const sdkError =
|
||||
error instanceof SDKError
|
||||
? error
|
||||
: new SDKError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
0, // httpStatus 0 indicates network-level failure
|
||||
"NETWORK_ERROR"
|
||||
);
|
||||
this.onNetworkError(sdkError, {
|
||||
method,
|
||||
path,
|
||||
isRetry: false,
|
||||
attempt: this.maxRetries, // All retries exhausted
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
@ -364,11 +328,8 @@ export class HttpClient {
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
attempt: number = 0,
|
||||
startTime?: number, // Track start time for timing across retries
|
||||
gatewayAttempt: number = 0 // Track gateway failover attempts
|
||||
startTime?: number // Track start time for timing across retries
|
||||
): Promise<any> {
|
||||
const currentGatewayUrl = this.getCurrentBaseURL();
|
||||
|
||||
try {
|
||||
const response = await this.fetch(url, options);
|
||||
|
||||
@ -393,50 +354,20 @@ export class HttpClient {
|
||||
error instanceof SDKError &&
|
||||
[408, 429, 500, 502, 503, 504].includes(error.httpStatus);
|
||||
|
||||
const isNetworkError =
|
||||
error instanceof TypeError ||
|
||||
(error instanceof Error && error.message.includes('fetch'));
|
||||
|
||||
// Retry on the same gateway first (for retryable HTTP errors)
|
||||
// Retry on same gateway for retryable HTTP errors
|
||||
if (isRetryableError && attempt < this.maxRetries) {
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn(
|
||||
`[HttpClient] Retrying request on same gateway (attempt ${attempt + 1}/${this.maxRetries}):`,
|
||||
currentGatewayUrl
|
||||
`[HttpClient] Retrying request (attempt ${attempt + 1}/${this.maxRetries})`
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, this.retryDelayMs * (attempt + 1))
|
||||
);
|
||||
return this.requestWithRetry(url, options, attempt + 1, startTime, gatewayAttempt);
|
||||
}
|
||||
|
||||
// If all retries on current gateway failed, mark it unhealthy and try next gateway
|
||||
if ((isNetworkError || isRetryableError) && gatewayAttempt < this.baseURLs.length - 1) {
|
||||
// Mark current gateway as unhealthy
|
||||
this.markGatewayUnhealthy(currentGatewayUrl);
|
||||
|
||||
// Try to move to next healthy gateway
|
||||
if (this.moveToNextGateway()) {
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn(
|
||||
`[HttpClient] Gateway exhausted retries, trying next gateway (${gatewayAttempt + 1}/${this.baseURLs.length - 1}):`,
|
||||
this.getCurrentBaseURL()
|
||||
);
|
||||
}
|
||||
|
||||
// Update URL to use the new gateway
|
||||
const currentPath = url.substring(url.indexOf('/', 8)); // Get path after protocol://host
|
||||
const newUrl = this.getCurrentBaseURL() + currentPath;
|
||||
|
||||
// Small delay before trying next gateway
|
||||
await new Promise((resolve) => setTimeout(resolve, this.retryDelayMs));
|
||||
|
||||
// Reset attempt counter for new gateway
|
||||
return this.requestWithRetry(newUrl, options, 0, startTime, gatewayAttempt + 1);
|
||||
}
|
||||
return this.requestWithRetry(url, options, attempt + 1, startTime);
|
||||
}
|
||||
|
||||
// All retries exhausted - throw error for app to handle
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -483,7 +414,7 @@ export class HttpClient {
|
||||
}
|
||||
): Promise<T> {
|
||||
const startTime = performance.now(); // Track upload start time
|
||||
const url = new URL(this.getCurrentBaseURL() + path);
|
||||
const url = new URL(this.baseURL + path);
|
||||
const headers: Record<string, string> = {
|
||||
...this.getAuthHeaders(path),
|
||||
// Don't set Content-Type - browser will set it with boundary
|
||||
@ -526,6 +457,25 @@ export class HttpClient {
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
// Call the network error callback if configured
|
||||
if (this.onNetworkError) {
|
||||
const sdkError =
|
||||
error instanceof SDKError
|
||||
? error
|
||||
: new SDKError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
0,
|
||||
"NETWORK_ERROR"
|
||||
);
|
||||
this.onNetworkError(sdkError, {
|
||||
method: "POST",
|
||||
path,
|
||||
isRetry: false,
|
||||
attempt: this.maxRetries,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
@ -536,7 +486,7 @@ export class HttpClient {
|
||||
* Get a binary response (returns Response object for streaming)
|
||||
*/
|
||||
async getBinary(path: string): Promise<Response> {
|
||||
const url = new URL(this.getCurrentBaseURL() + path);
|
||||
const url = new URL(this.baseURL + path);
|
||||
const headers: Record<string, string> = {
|
||||
...this.getAuthHeaders(path),
|
||||
};
|
||||
@ -554,17 +504,33 @@ export class HttpClient {
|
||||
const response = await this.fetch(url.toString(), fetchOptions);
|
||||
if (!response.ok) {
|
||||
clearTimeout(timeoutId);
|
||||
const error = await response.json().catch(() => ({
|
||||
const errorBody = await response.json().catch(() => ({
|
||||
error: response.statusText,
|
||||
}));
|
||||
throw SDKError.fromResponse(response.status, error);
|
||||
throw SDKError.fromResponse(response.status, errorBody);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error instanceof SDKError) {
|
||||
throw error;
|
||||
|
||||
// Call the network error callback if configured
|
||||
if (this.onNetworkError) {
|
||||
const sdkError =
|
||||
error instanceof SDKError
|
||||
? error
|
||||
: new SDKError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
0,
|
||||
"NETWORK_ERROR"
|
||||
);
|
||||
this.onNetworkError(sdkError, {
|
||||
method: "GET",
|
||||
path,
|
||||
isRetry: false,
|
||||
attempt: 0,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
10
src/core/index.ts
Normal file
10
src/core/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export { HttpClient, type HttpClientConfig, type NetworkErrorCallback, type NetworkErrorContext } from "./http";
|
||||
export { WSClient, type WSClientConfig } from "./ws";
|
||||
export type { IHttpTransport, RequestOptions } from "./interfaces/IHttpTransport";
|
||||
export type { IWebSocketClient } from "./interfaces/IWebSocketClient";
|
||||
export type { IAuthStrategy, RequestContext } from "./interfaces/IAuthStrategy";
|
||||
export type { IRetryPolicy } from "./interfaces/IRetryPolicy";
|
||||
export { PathBasedAuthStrategy } from "./transport/AuthHeaderStrategy";
|
||||
export { ExponentialBackoffRetryPolicy } from "./transport/RequestRetryPolicy";
|
||||
export { RequestLogger } from "./transport/RequestLogger";
|
||||
export { TLSConfiguration } from "./transport/TLSConfiguration";
|
||||
28
src/core/interfaces/IAuthStrategy.ts
Normal file
28
src/core/interfaces/IAuthStrategy.ts
Normal file
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Request context for authentication
|
||||
*/
|
||||
export interface RequestContext {
|
||||
path: string;
|
||||
method: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication strategy interface
|
||||
* Provides abstraction for different authentication header strategies
|
||||
*/
|
||||
export interface IAuthStrategy {
|
||||
/**
|
||||
* Get authentication headers for a request
|
||||
*/
|
||||
getHeaders(context: RequestContext): Record<string, string>;
|
||||
|
||||
/**
|
||||
* Set API key
|
||||
*/
|
||||
setApiKey(apiKey?: string): void;
|
||||
|
||||
/**
|
||||
* Set JWT token
|
||||
*/
|
||||
setJwt(jwt?: string): void;
|
||||
}
|
||||
73
src/core/interfaces/IHttpTransport.ts
Normal file
73
src/core/interfaces/IHttpTransport.ts
Normal file
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* HTTP Request options
|
||||
*/
|
||||
export interface RequestOptions {
|
||||
headers?: Record<string, string>;
|
||||
query?: Record<string, string | number | boolean>;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP Transport abstraction interface
|
||||
* Provides a testable abstraction layer for HTTP operations
|
||||
*/
|
||||
export interface IHttpTransport {
|
||||
/**
|
||||
* Perform GET request
|
||||
*/
|
||||
get<T = any>(path: string, options?: RequestOptions): Promise<T>;
|
||||
|
||||
/**
|
||||
* Perform POST request
|
||||
*/
|
||||
post<T = any>(path: string, body?: any, options?: RequestOptions): Promise<T>;
|
||||
|
||||
/**
|
||||
* Perform PUT request
|
||||
*/
|
||||
put<T = any>(path: string, body?: any, options?: RequestOptions): Promise<T>;
|
||||
|
||||
/**
|
||||
* Perform DELETE request
|
||||
*/
|
||||
delete<T = any>(path: string, options?: RequestOptions): Promise<T>;
|
||||
|
||||
/**
|
||||
* Upload file using multipart/form-data
|
||||
*/
|
||||
uploadFile<T = any>(
|
||||
path: string,
|
||||
formData: FormData,
|
||||
options?: { timeout?: number }
|
||||
): Promise<T>;
|
||||
|
||||
/**
|
||||
* Get binary response (returns Response object for streaming)
|
||||
*/
|
||||
getBinary(path: string): Promise<Response>;
|
||||
|
||||
/**
|
||||
* Get base URL
|
||||
*/
|
||||
getBaseURL(): string;
|
||||
|
||||
/**
|
||||
* Get API key
|
||||
*/
|
||||
getApiKey(): string | undefined;
|
||||
|
||||
/**
|
||||
* Get current token (JWT or API key)
|
||||
*/
|
||||
getToken(): string | undefined;
|
||||
|
||||
/**
|
||||
* Set API key for authentication
|
||||
*/
|
||||
setApiKey(apiKey?: string): void;
|
||||
|
||||
/**
|
||||
* Set JWT token for authentication
|
||||
*/
|
||||
setJwt(jwt?: string): void;
|
||||
}
|
||||
20
src/core/interfaces/IRetryPolicy.ts
Normal file
20
src/core/interfaces/IRetryPolicy.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Retry policy interface
|
||||
* Provides abstraction for retry logic and backoff strategies
|
||||
*/
|
||||
export interface IRetryPolicy {
|
||||
/**
|
||||
* Determine if request should be retried
|
||||
*/
|
||||
shouldRetry(error: any, attempt: number): boolean;
|
||||
|
||||
/**
|
||||
* Get delay before next retry attempt (in milliseconds)
|
||||
*/
|
||||
getDelay(attempt: number): number;
|
||||
|
||||
/**
|
||||
* Get maximum number of retry attempts
|
||||
*/
|
||||
getMaxRetries(): number;
|
||||
}
|
||||
60
src/core/interfaces/IWebSocketClient.ts
Normal file
60
src/core/interfaces/IWebSocketClient.ts
Normal file
@ -0,0 +1,60 @@
|
||||
/**
|
||||
* WebSocket Client abstraction interface
|
||||
* Provides a testable abstraction layer for WebSocket operations
|
||||
*/
|
||||
export interface IWebSocketClient {
|
||||
/**
|
||||
* Connect to WebSocket server
|
||||
*/
|
||||
connect(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Close WebSocket connection
|
||||
*/
|
||||
close(): void;
|
||||
|
||||
/**
|
||||
* Send data through WebSocket
|
||||
*/
|
||||
send(data: string): void;
|
||||
|
||||
/**
|
||||
* Register message handler
|
||||
*/
|
||||
onMessage(handler: (data: string) => void): void;
|
||||
|
||||
/**
|
||||
* Unregister message handler
|
||||
*/
|
||||
offMessage(handler: (data: string) => void): void;
|
||||
|
||||
/**
|
||||
* Register error handler
|
||||
*/
|
||||
onError(handler: (error: Error) => void): void;
|
||||
|
||||
/**
|
||||
* Unregister error handler
|
||||
*/
|
||||
offError(handler: (error: Error) => void): void;
|
||||
|
||||
/**
|
||||
* Register close handler
|
||||
*/
|
||||
onClose(handler: () => void): void;
|
||||
|
||||
/**
|
||||
* Unregister close handler
|
||||
*/
|
||||
offClose(handler: () => void): void;
|
||||
|
||||
/**
|
||||
* Check if WebSocket is connected
|
||||
*/
|
||||
isConnected(): boolean;
|
||||
|
||||
/**
|
||||
* Get WebSocket URL
|
||||
*/
|
||||
get url(): string;
|
||||
}
|
||||
4
src/core/interfaces/index.ts
Normal file
4
src/core/interfaces/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export type { IHttpTransport, RequestOptions } from "./IHttpTransport";
|
||||
export type { IWebSocketClient } from "./IWebSocketClient";
|
||||
export type { IAuthStrategy, RequestContext } from "./IAuthStrategy";
|
||||
export type { IRetryPolicy } from "./IRetryPolicy";
|
||||
108
src/core/transport/AuthHeaderStrategy.ts
Normal file
108
src/core/transport/AuthHeaderStrategy.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import type { IAuthStrategy, RequestContext } from "../interfaces/IAuthStrategy";
|
||||
|
||||
/**
|
||||
* Authentication type for different operations
|
||||
*/
|
||||
type AuthType = "api-key-only" | "api-key-preferred" | "jwt-preferred" | "both";
|
||||
|
||||
/**
|
||||
* Path-based authentication strategy
|
||||
* Determines which auth credentials to use based on the request path
|
||||
*/
|
||||
export class PathBasedAuthStrategy implements IAuthStrategy {
|
||||
private apiKey?: string;
|
||||
private jwt?: string;
|
||||
|
||||
/**
|
||||
* Mapping of path patterns to auth types
|
||||
*/
|
||||
private readonly authRules: Array<{ pattern: string; type: AuthType }> = [
|
||||
// Database, PubSub, Proxy, Cache: prefer API key
|
||||
{ pattern: "/v1/rqlite/", type: "api-key-only" },
|
||||
{ pattern: "/v1/pubsub/", type: "api-key-only" },
|
||||
{ pattern: "/v1/proxy/", type: "api-key-only" },
|
||||
{ pattern: "/v1/cache/", type: "api-key-only" },
|
||||
// Auth operations: prefer API key
|
||||
{ pattern: "/v1/auth/", type: "api-key-preferred" },
|
||||
];
|
||||
|
||||
constructor(apiKey?: string, jwt?: string) {
|
||||
this.apiKey = apiKey;
|
||||
this.jwt = jwt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication headers for a request
|
||||
*/
|
||||
getHeaders(context: RequestContext): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
const authType = this.detectAuthType(context.path);
|
||||
|
||||
switch (authType) {
|
||||
case "api-key-only":
|
||||
if (this.apiKey) {
|
||||
headers["X-API-Key"] = this.apiKey;
|
||||
} else if (this.jwt) {
|
||||
// Fallback to JWT if no API key
|
||||
headers["Authorization"] = `Bearer ${this.jwt}`;
|
||||
}
|
||||
break;
|
||||
|
||||
case "api-key-preferred":
|
||||
if (this.apiKey) {
|
||||
headers["X-API-Key"] = this.apiKey;
|
||||
}
|
||||
if (this.jwt) {
|
||||
headers["Authorization"] = `Bearer ${this.jwt}`;
|
||||
}
|
||||
break;
|
||||
|
||||
case "jwt-preferred":
|
||||
if (this.jwt) {
|
||||
headers["Authorization"] = `Bearer ${this.jwt}`;
|
||||
}
|
||||
if (this.apiKey) {
|
||||
headers["X-API-Key"] = this.apiKey;
|
||||
}
|
||||
break;
|
||||
|
||||
case "both":
|
||||
if (this.jwt) {
|
||||
headers["Authorization"] = `Bearer ${this.jwt}`;
|
||||
}
|
||||
if (this.apiKey) {
|
||||
headers["X-API-Key"] = this.apiKey;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set API key
|
||||
*/
|
||||
setApiKey(apiKey?: string): void {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set JWT token
|
||||
*/
|
||||
setJwt(jwt?: string): void {
|
||||
this.jwt = jwt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect auth type based on path
|
||||
*/
|
||||
private detectAuthType(path: string): AuthType {
|
||||
for (const rule of this.authRules) {
|
||||
if (path.includes(rule.pattern)) {
|
||||
return rule.type;
|
||||
}
|
||||
}
|
||||
// Default: send both if available
|
||||
return "both";
|
||||
}
|
||||
}
|
||||
116
src/core/transport/RequestLogger.ts
Normal file
116
src/core/transport/RequestLogger.ts
Normal file
@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Request logger for debugging HTTP operations
|
||||
*/
|
||||
export class RequestLogger {
|
||||
private readonly debug: boolean;
|
||||
|
||||
constructor(debug: boolean = false) {
|
||||
this.debug = debug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log successful request
|
||||
*/
|
||||
logSuccess(
|
||||
method: string,
|
||||
path: string,
|
||||
duration: number,
|
||||
queryDetails?: string
|
||||
): void {
|
||||
if (typeof console === "undefined") return;
|
||||
|
||||
const logMessage = `[HttpClient] ${method} ${path} completed in ${duration.toFixed(2)}ms`;
|
||||
|
||||
if (queryDetails && this.debug) {
|
||||
console.log(logMessage);
|
||||
console.log(`[HttpClient] ${queryDetails}`);
|
||||
} else {
|
||||
console.log(logMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log failed request
|
||||
*/
|
||||
logError(
|
||||
method: string,
|
||||
path: string,
|
||||
duration: number,
|
||||
error: any,
|
||||
queryDetails?: string
|
||||
): void {
|
||||
if (typeof console === "undefined") return;
|
||||
|
||||
// Special handling for 404 on find-one (expected behavior)
|
||||
const is404FindOne =
|
||||
path === "/v1/rqlite/find-one" &&
|
||||
error?.httpStatus === 404;
|
||||
|
||||
if (is404FindOne) {
|
||||
console.warn(
|
||||
`[HttpClient] ${method} ${path} returned 404 after ${duration.toFixed(2)}ms (expected for optional lookups)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = `[HttpClient] ${method} ${path} failed after ${duration.toFixed(2)}ms:`;
|
||||
console.error(errorMessage, error);
|
||||
|
||||
if (queryDetails && this.debug) {
|
||||
console.error(`[HttpClient] ${queryDetails}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract query details from request for logging
|
||||
*/
|
||||
extractQueryDetails(path: string, body?: any): string | null {
|
||||
if (!this.debug) return null;
|
||||
|
||||
const isRqliteOperation = path.includes("/v1/rqlite/");
|
||||
if (!isRqliteOperation || !body) return null;
|
||||
|
||||
try {
|
||||
const parsedBody = typeof body === "string" ? JSON.parse(body) : body;
|
||||
|
||||
// Direct SQL query
|
||||
if (parsedBody.sql) {
|
||||
let details = `SQL: ${parsedBody.sql}`;
|
||||
if (parsedBody.args && parsedBody.args.length > 0) {
|
||||
details += ` | Args: [${parsedBody.args
|
||||
.map((a: any) => (typeof a === "string" ? `"${a}"` : a))
|
||||
.join(", ")}]`;
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
// Table-based query
|
||||
if (parsedBody.table) {
|
||||
let details = `Table: ${parsedBody.table}`;
|
||||
if (parsedBody.criteria && Object.keys(parsedBody.criteria).length > 0) {
|
||||
details += ` | Criteria: ${JSON.stringify(parsedBody.criteria)}`;
|
||||
}
|
||||
if (parsedBody.options) {
|
||||
details += ` | Options: ${JSON.stringify(parsedBody.options)}`;
|
||||
}
|
||||
if (parsedBody.select) {
|
||||
details += ` | Select: ${JSON.stringify(parsedBody.select)}`;
|
||||
}
|
||||
if (parsedBody.where) {
|
||||
details += ` | Where: ${JSON.stringify(parsedBody.where)}`;
|
||||
}
|
||||
if (parsedBody.limit) {
|
||||
details += ` | Limit: ${parsedBody.limit}`;
|
||||
}
|
||||
if (parsedBody.offset) {
|
||||
details += ` | Offset: ${parsedBody.offset}`;
|
||||
}
|
||||
return details;
|
||||
}
|
||||
} catch {
|
||||
// Failed to parse, ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
53
src/core/transport/RequestRetryPolicy.ts
Normal file
53
src/core/transport/RequestRetryPolicy.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import type { IRetryPolicy } from "../interfaces/IRetryPolicy";
|
||||
import { SDKError } from "../../errors";
|
||||
|
||||
/**
|
||||
* Exponential backoff retry policy
|
||||
* Retries failed requests with increasing delays
|
||||
*/
|
||||
export class ExponentialBackoffRetryPolicy implements IRetryPolicy {
|
||||
private readonly maxRetries: number;
|
||||
private readonly baseDelayMs: number;
|
||||
|
||||
/**
|
||||
* HTTP status codes that should trigger a retry
|
||||
*/
|
||||
private readonly retryableStatusCodes = [408, 429, 500, 502, 503, 504];
|
||||
|
||||
constructor(maxRetries: number = 3, baseDelayMs: number = 1000) {
|
||||
this.maxRetries = maxRetries;
|
||||
this.baseDelayMs = baseDelayMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if request should be retried
|
||||
*/
|
||||
shouldRetry(error: any, attempt: number): boolean {
|
||||
// Don't retry if max attempts reached
|
||||
if (attempt >= this.maxRetries) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Retry on retryable HTTP errors
|
||||
if (error instanceof SDKError) {
|
||||
return this.retryableStatusCodes.includes(error.httpStatus);
|
||||
}
|
||||
|
||||
// Don't retry other errors
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get delay before next retry (exponential backoff)
|
||||
*/
|
||||
getDelay(attempt: number): number {
|
||||
return this.baseDelayMs * (attempt + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum number of retry attempts
|
||||
*/
|
||||
getMaxRetries(): number {
|
||||
return this.maxRetries;
|
||||
}
|
||||
}
|
||||
53
src/core/transport/TLSConfiguration.ts
Normal file
53
src/core/transport/TLSConfiguration.ts
Normal file
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* TLS Configuration for development/staging environments
|
||||
*
|
||||
* WARNING: Only use this in development/testing environments!
|
||||
* DO NOT disable certificate validation in production.
|
||||
*/
|
||||
export class TLSConfiguration {
|
||||
/**
|
||||
* Create fetch function with proper TLS configuration
|
||||
*/
|
||||
static createFetchWithTLSConfig(): typeof fetch {
|
||||
// Only allow insecure TLS in development
|
||||
if (this.shouldAllowInsecure()) {
|
||||
this.configureInsecureTLS();
|
||||
}
|
||||
|
||||
return globalThis.fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if insecure TLS should be allowed
|
||||
*/
|
||||
private static shouldAllowInsecure(): boolean {
|
||||
// Check if we're in Node.js environment
|
||||
if (typeof process === "undefined" || !process.versions?.node) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only allow in non-production with explicit flag
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const allowInsecure = process.env.DEBROS_ALLOW_INSECURE_TLS === "true";
|
||||
|
||||
return !isProduction && allowInsecure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Node.js to allow insecure TLS
|
||||
* WARNING: Only call in development!
|
||||
*/
|
||||
private static configureInsecureTLS(): void {
|
||||
if (typeof process !== "undefined" && process.env) {
|
||||
// Allow self-signed/staging certificates for development
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
||||
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn(
|
||||
"[TLSConfiguration] WARNING: TLS certificate validation disabled for development. " +
|
||||
"DO NOT use in production!"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
4
src/core/transport/index.ts
Normal file
4
src/core/transport/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { PathBasedAuthStrategy } from "./AuthHeaderStrategy";
|
||||
export { ExponentialBackoffRetryPolicy } from "./RequestRetryPolicy";
|
||||
export { RequestLogger } from "./RequestLogger";
|
||||
export { TLSConfiguration } from "./TLSConfiguration";
|
||||
@ -1,11 +1,17 @@
|
||||
import WebSocket from "isomorphic-ws";
|
||||
import { SDKError } from "../errors";
|
||||
import { NetworkErrorCallback } from "./http";
|
||||
|
||||
export interface WSClientConfig {
|
||||
wsURL: string;
|
||||
timeout?: number;
|
||||
authToken?: string;
|
||||
WebSocket?: typeof WebSocket;
|
||||
/**
|
||||
* Callback invoked on WebSocket errors.
|
||||
* Use this to trigger gateway failover at the application layer.
|
||||
*/
|
||||
onNetworkError?: NetworkErrorCallback;
|
||||
}
|
||||
|
||||
export type WSMessageHandler = (data: string) => void;
|
||||
@ -15,13 +21,15 @@ export type WSOpenHandler = () => void;
|
||||
|
||||
/**
|
||||
* Simple WebSocket client with minimal abstractions
|
||||
* No complex reconnection, no heartbeats - keep it simple
|
||||
* No complex reconnection, no failover - keep it simple
|
||||
* Gateway failover is handled at the application layer
|
||||
*/
|
||||
export class WSClient {
|
||||
private url: string;
|
||||
private wsURL: string;
|
||||
private timeout: number;
|
||||
private authToken?: string;
|
||||
private WebSocketClass: typeof WebSocket;
|
||||
private onNetworkError?: NetworkErrorCallback;
|
||||
|
||||
private ws?: WebSocket;
|
||||
private messageHandlers: Set<WSMessageHandler> = new Set();
|
||||
@ -31,10 +39,25 @@ export class WSClient {
|
||||
private isClosed = false;
|
||||
|
||||
constructor(config: WSClientConfig) {
|
||||
this.url = config.wsURL;
|
||||
this.wsURL = config.wsURL;
|
||||
this.timeout = config.timeout ?? 30000;
|
||||
this.authToken = config.authToken;
|
||||
this.WebSocketClass = config.WebSocket ?? WebSocket;
|
||||
this.onNetworkError = config.onNetworkError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the network error callback
|
||||
*/
|
||||
setOnNetworkError(callback: NetworkErrorCallback | undefined): void {
|
||||
this.onNetworkError = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current WebSocket URL
|
||||
*/
|
||||
get url(): string {
|
||||
return this.wsURL;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -49,14 +72,24 @@ export class WSClient {
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
this.ws?.close();
|
||||
reject(
|
||||
new SDKError("WebSocket connection timeout", 408, "WS_TIMEOUT")
|
||||
);
|
||||
const error = new SDKError("WebSocket connection timeout", 408, "WS_TIMEOUT");
|
||||
|
||||
// Call the network error callback if configured
|
||||
if (this.onNetworkError) {
|
||||
this.onNetworkError(error, {
|
||||
method: "WS",
|
||||
path: this.wsURL,
|
||||
isRetry: false,
|
||||
attempt: 0,
|
||||
});
|
||||
}
|
||||
|
||||
reject(error);
|
||||
}, this.timeout);
|
||||
|
||||
this.ws.addEventListener("open", () => {
|
||||
clearTimeout(timeout);
|
||||
console.log("[WSClient] Connected to", this.url);
|
||||
console.log("[WSClient] Connected to", this.wsURL);
|
||||
this.openHandlers.forEach((handler) => handler());
|
||||
resolve();
|
||||
});
|
||||
@ -70,7 +103,19 @@ export class WSClient {
|
||||
console.error("[WSClient] WebSocket error:", event);
|
||||
clearTimeout(timeout);
|
||||
const error = new SDKError("WebSocket error", 500, "WS_ERROR", event);
|
||||
|
||||
// Call the network error callback if configured
|
||||
if (this.onNetworkError) {
|
||||
this.onNetworkError(error, {
|
||||
method: "WS",
|
||||
path: this.wsURL,
|
||||
isRetry: false,
|
||||
attempt: 0,
|
||||
});
|
||||
}
|
||||
|
||||
this.errorHandlers.forEach((handler) => handler(error));
|
||||
reject(error);
|
||||
});
|
||||
|
||||
this.ws.addEventListener("close", () => {
|
||||
@ -88,7 +133,7 @@ export class WSClient {
|
||||
* Build WebSocket URL with auth token
|
||||
*/
|
||||
private buildWSUrl(): string {
|
||||
let url = this.url;
|
||||
let url = this.wsURL;
|
||||
|
||||
if (this.authToken) {
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
|
||||
13
src/db/index.ts
Normal file
13
src/db/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export { DBClient } from "./client";
|
||||
export { QueryBuilder } from "./qb";
|
||||
export { Repository } from "./repository";
|
||||
export type {
|
||||
Entity,
|
||||
QueryResponse,
|
||||
TransactionOp,
|
||||
TransactionRequest,
|
||||
SelectOptions,
|
||||
FindOptions,
|
||||
ColumnDefinition,
|
||||
} from "./types";
|
||||
export { extractTableName, extractPrimaryKey } from "./types";
|
||||
2
src/functions/index.ts
Normal file
2
src/functions/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { FunctionsClient, type FunctionsClientConfig } from "./client";
|
||||
export type { FunctionResponse, SuccessResponse } from "./types";
|
||||
21
src/index.ts
21
src/index.ts
@ -1,4 +1,4 @@
|
||||
import { HttpClient, HttpClientConfig } from "./core/http";
|
||||
import { HttpClient, HttpClientConfig, NetworkErrorCallback } from "./core/http";
|
||||
import { AuthClient } from "./auth/client";
|
||||
import { DBClient } from "./db/client";
|
||||
import { PubSubClient } from "./pubsub/client";
|
||||
@ -17,9 +17,14 @@ export interface ClientConfig extends Omit<HttpClientConfig, "fetch"> {
|
||||
apiKey?: string;
|
||||
jwt?: string;
|
||||
storage?: StorageAdapter;
|
||||
wsConfig?: Partial<WSClientConfig>;
|
||||
wsConfig?: Partial<Omit<WSClientConfig, "wsURL">>;
|
||||
functionsConfig?: FunctionsClientConfig;
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* Callback invoked on network errors (HTTP and WebSocket).
|
||||
* Use this to trigger gateway failover at the application layer.
|
||||
*/
|
||||
onNetworkError?: NetworkErrorCallback;
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
@ -38,7 +43,9 @@ export function createClient(config: ClientConfig): Client {
|
||||
timeout: config.timeout,
|
||||
maxRetries: config.maxRetries,
|
||||
retryDelayMs: config.retryDelayMs,
|
||||
debug: config.debug,
|
||||
fetch: config.fetch,
|
||||
onNetworkError: config.onNetworkError,
|
||||
});
|
||||
|
||||
const auth = new AuthClient({
|
||||
@ -48,17 +55,14 @@ export function createClient(config: ClientConfig): Client {
|
||||
jwt: config.jwt,
|
||||
});
|
||||
|
||||
// Derive WebSocket URL from baseURL if not explicitly provided
|
||||
// If multiple base URLs are provided, use the first one for WebSocket (primary gateway)
|
||||
const primaryBaseURL = Array.isArray(config.baseURL) ? config.baseURL[0] : config.baseURL;
|
||||
const wsURL =
|
||||
config.wsConfig?.wsURL ??
|
||||
primaryBaseURL.replace(/^http/, "ws").replace(/\/$/, "");
|
||||
// Derive WebSocket URL from baseURL
|
||||
const wsURL = config.baseURL.replace(/^http/, "ws").replace(/\/$/, "");
|
||||
|
||||
const db = new DBClient(httpClient);
|
||||
const pubsub = new PubSubClient(httpClient, {
|
||||
...config.wsConfig,
|
||||
wsURL,
|
||||
onNetworkError: config.onNetworkError,
|
||||
});
|
||||
const network = new NetworkClient(httpClient);
|
||||
const cache = new CacheClient(httpClient);
|
||||
@ -77,6 +81,7 @@ export function createClient(config: ClientConfig): Client {
|
||||
}
|
||||
|
||||
export { HttpClient } from "./core/http";
|
||||
export type { NetworkErrorCallback, NetworkErrorContext } from "./core/http";
|
||||
export { WSClient } from "./core/ws";
|
||||
export { AuthClient } from "./auth/client";
|
||||
export { DBClient } from "./db/client";
|
||||
|
||||
7
src/network/index.ts
Normal file
7
src/network/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export { NetworkClient } from "./client";
|
||||
export type {
|
||||
PeerInfo,
|
||||
NetworkStatus,
|
||||
ProxyRequest,
|
||||
ProxyResponse,
|
||||
} from "./client";
|
||||
@ -55,7 +55,7 @@ function base64Decode(b64: string): string {
|
||||
|
||||
/**
|
||||
* Simple PubSub client - one WebSocket connection per topic
|
||||
* No connection pooling, no reference counting - keep it simple
|
||||
* Gateway failover is handled at the application layer
|
||||
*/
|
||||
export class PubSubClient {
|
||||
private httpClient: HttpClient;
|
||||
|
||||
12
src/pubsub/index.ts
Normal file
12
src/pubsub/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export { PubSubClient, Subscription } from "./client";
|
||||
export type {
|
||||
PubSubMessage,
|
||||
RawEnvelope,
|
||||
MessageHandler,
|
||||
ErrorHandler,
|
||||
CloseHandler,
|
||||
PresenceMember,
|
||||
PresenceResponse,
|
||||
PresenceOptions,
|
||||
SubscribeOptions,
|
||||
} from "./types";
|
||||
7
src/storage/index.ts
Normal file
7
src/storage/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export { StorageClient } from "./client";
|
||||
export type {
|
||||
StorageUploadResponse,
|
||||
StoragePinRequest,
|
||||
StoragePinResponse,
|
||||
StorageStatus,
|
||||
} from "./client";
|
||||
68
src/utils/codec.ts
Normal file
68
src/utils/codec.ts
Normal file
@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Base64 Codec for cross-platform encoding/decoding
|
||||
* Works in both Node.js and browser environments
|
||||
*/
|
||||
export class Base64Codec {
|
||||
/**
|
||||
* Encode string or Uint8Array to base64
|
||||
*/
|
||||
static encode(input: string | Uint8Array): string {
|
||||
if (typeof input === "string") {
|
||||
return this.encodeString(input);
|
||||
}
|
||||
return this.encodeBytes(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode string to base64
|
||||
*/
|
||||
static encodeString(str: string): string {
|
||||
if (this.isNode()) {
|
||||
return Buffer.from(str).toString("base64");
|
||||
}
|
||||
// Browser
|
||||
return btoa(
|
||||
encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) =>
|
||||
String.fromCharCode(parseInt(p1, 16))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode Uint8Array to base64
|
||||
*/
|
||||
static encodeBytes(bytes: Uint8Array): string {
|
||||
if (this.isNode()) {
|
||||
return Buffer.from(bytes).toString("base64");
|
||||
}
|
||||
// Browser
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode base64 to string
|
||||
*/
|
||||
static decode(b64: string): string {
|
||||
if (this.isNode()) {
|
||||
return Buffer.from(b64, "base64").toString("utf-8");
|
||||
}
|
||||
// Browser
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in Node.js environment
|
||||
*/
|
||||
private static isNode(): boolean {
|
||||
return typeof Buffer !== "undefined";
|
||||
}
|
||||
}
|
||||
3
src/utils/index.ts
Normal file
3
src/utils/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { Base64Codec } from "./codec";
|
||||
export { retryWithBackoff, type RetryConfig } from "./retry";
|
||||
export { Platform } from "./platform";
|
||||
44
src/utils/platform.ts
Normal file
44
src/utils/platform.ts
Normal file
@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Platform detection utilities
|
||||
* Helps determine runtime environment (Node.js vs Browser)
|
||||
*/
|
||||
export const Platform = {
|
||||
/**
|
||||
* Check if running in Node.js
|
||||
*/
|
||||
isNode: (): boolean => {
|
||||
return typeof process !== "undefined" && !!process.versions?.node;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if running in browser
|
||||
*/
|
||||
isBrowser: (): boolean => {
|
||||
return typeof window !== "undefined";
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if localStorage is available
|
||||
*/
|
||||
hasLocalStorage: (): boolean => {
|
||||
try {
|
||||
return typeof localStorage !== "undefined" && localStorage !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if Buffer is available (Node.js)
|
||||
*/
|
||||
hasBuffer: (): boolean => {
|
||||
return typeof Buffer !== "undefined";
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if btoa/atob are available (Browser)
|
||||
*/
|
||||
hasBase64: (): boolean => {
|
||||
return typeof btoa !== "undefined" && typeof atob !== "undefined";
|
||||
},
|
||||
};
|
||||
58
src/utils/retry.ts
Normal file
58
src/utils/retry.ts
Normal file
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Retry configuration
|
||||
*/
|
||||
export interface RetryConfig {
|
||||
/**
|
||||
* Maximum number of retry attempts
|
||||
*/
|
||||
maxAttempts: number;
|
||||
|
||||
/**
|
||||
* Function to calculate backoff delay in milliseconds
|
||||
*/
|
||||
backoffMs: (attempt: number) => number;
|
||||
|
||||
/**
|
||||
* Function to determine if error should trigger retry
|
||||
*/
|
||||
shouldRetry: (error: any) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry an operation with exponential backoff
|
||||
* @param operation - The async operation to retry
|
||||
* @param config - Retry configuration
|
||||
* @returns Promise resolving to operation result
|
||||
* @throws Last error if all retries exhausted
|
||||
*/
|
||||
export async function retryWithBackoff<T>(
|
||||
operation: () => Promise<T>,
|
||||
config: RetryConfig
|
||||
): Promise<T> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error: any) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
// Check if we should retry this error
|
||||
if (!config.shouldRetry(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// If this was the last attempt, throw
|
||||
if (attempt === config.maxAttempts) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Wait before next attempt
|
||||
const delay = config.backoffMs(attempt);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback (should never reach here)
|
||||
throw lastError || new Error("Retry failed");
|
||||
}
|
||||
@ -85,7 +85,7 @@ describe("Storage", () => {
|
||||
const client = await createTestClient();
|
||||
// First upload and pin a file
|
||||
const testContent = "File for status check";
|
||||
const testFile = new File([testContent], "status-test.txt", {
|
||||
const testFile = new File([testContent], "status-test", {
|
||||
type: "text/plain",
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user