mirror of
https://github.com/DeBrosOfficial/network-ts-sdk.git
synced 2025-12-14 19:18:49 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d744e26a19 | ||
|
|
681299efdd | ||
|
|
3db9f4d8b8 | ||
|
|
ca81e60bcb | ||
|
|
091a6d5751 | ||
|
|
c7ef91f8d6 | ||
|
|
54c61e6a47 | ||
|
|
a02c7474ab | ||
|
|
06d58fe85b | ||
|
|
2cdb78ee1d | ||
|
|
cee0cd62a9 | ||
|
|
51f7c433c7 | ||
|
|
64cfe078f0 | ||
|
|
5bfb042646 | ||
|
|
e233696166 | ||
|
|
e30e81d0c9 | ||
|
|
272c6b872c | ||
|
|
8c9a900e88 | ||
|
|
23a742e5d4 | ||
|
|
6f5be86a02 | ||
|
|
a97fb21200 | ||
|
|
0e95ad49c5 | ||
|
|
fa357668cb | ||
|
|
5c564b6327 | ||
|
|
76bb82d4f8 | ||
|
|
dcf8efe428 | ||
|
|
c6dfb0bfed | ||
|
|
2de0cb1983 | ||
|
|
eab542952e | ||
|
|
acf9540daa | ||
|
|
f2d8d35a35 | ||
|
|
9d9f64c11e | ||
|
|
dab820bb25 | ||
|
|
4459fc6c49 |
31
.github/workflows/publish-npm.yml
vendored
31
.github/workflows/publish-npm.yml
vendored
@ -21,39 +21,12 @@ jobs:
|
|||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: npm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Build SDK
|
- name: Build SDK
|
||||||
run: pnpm build
|
run: npm run build
|
||||||
|
|
||||||
- name: Publish to npm
|
- name: Publish to npm
|
||||||
run: npm publish --access public
|
run: npm publish --access public
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
publish-github:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js for GitHub Packages
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '18'
|
|
||||||
registry-url: 'https://npm.pkg.github.com'
|
|
||||||
scope: '@network'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build SDK
|
|
||||||
run: pnpm build
|
|
||||||
|
|
||||||
- name: Publish to GitHub Packages
|
|
||||||
run: npm publish
|
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|||||||
109
README.md
109
README.md
@ -1,4 +1,4 @@
|
|||||||
# @network/sdk - TypeScript SDK for DeBros Network
|
# @debros/network-ts-sdk - TypeScript SDK for DeBros Network
|
||||||
|
|
||||||
A modern, isomorphic TypeScript SDK for the DeBros Network gateway. Works seamlessly in both Node.js and browser environments with support for database operations, pub/sub messaging, and network management.
|
A modern, isomorphic TypeScript SDK for the DeBros Network gateway. Works seamlessly in both Node.js and browser environments with support for database operations, pub/sub messaging, and network management.
|
||||||
|
|
||||||
@ -14,7 +14,7 @@ A modern, isomorphic TypeScript SDK for the DeBros Network gateway. Works seamle
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install @network/network-ts-sdk
|
npm install @debros/network-ts-sdk
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@ -22,7 +22,7 @@ npm install @network/network-ts-sdk
|
|||||||
### Initialize the Client
|
### Initialize the Client
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { createClient } from "@network/sdk";
|
import { createClient } from "@debros/network-ts-sdk";
|
||||||
|
|
||||||
const client = createClient({
|
const client = createClient({
|
||||||
baseURL: "http://localhost:6001",
|
baseURL: "http://localhost:6001",
|
||||||
@ -122,10 +122,23 @@ const results = await client.db.transaction([
|
|||||||
|
|
||||||
### Pub/Sub Messaging
|
### Pub/Sub Messaging
|
||||||
|
|
||||||
|
The SDK provides a robust pub/sub client with:
|
||||||
|
|
||||||
|
- **Multi-subscriber support**: Multiple connections can subscribe to the same topic
|
||||||
|
- **Namespace isolation**: Topics are scoped to your authenticated namespace
|
||||||
|
- **Server timestamps**: Messages preserve server-side timestamps
|
||||||
|
- **Binary-safe**: Supports both string and binary (`Uint8Array`) payloads
|
||||||
|
- **Strict envelope validation**: Type-safe message parsing with error handling
|
||||||
|
|
||||||
#### Publish a Message
|
#### Publish a Message
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
// Publish a string message
|
||||||
await client.pubsub.publish("notifications", "Hello, Network!");
|
await client.pubsub.publish("notifications", "Hello, Network!");
|
||||||
|
|
||||||
|
// Publish binary data
|
||||||
|
const binaryData = new Uint8Array([1, 2, 3, 4]);
|
||||||
|
await client.pubsub.publish("binary-topic", binaryData);
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Subscribe to Topics
|
#### Subscribe to Topics
|
||||||
@ -133,7 +146,9 @@ await client.pubsub.publish("notifications", "Hello, Network!");
|
|||||||
```typescript
|
```typescript
|
||||||
const subscription = await client.pubsub.subscribe("notifications", {
|
const subscription = await client.pubsub.subscribe("notifications", {
|
||||||
onMessage: (msg) => {
|
onMessage: (msg) => {
|
||||||
console.log("Received:", msg.data);
|
console.log("Topic:", msg.topic);
|
||||||
|
console.log("Data:", msg.data);
|
||||||
|
console.log("Server timestamp:", new Date(msg.timestamp));
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error("Subscription error:", err);
|
console.error("Subscription error:", err);
|
||||||
@ -147,6 +162,52 @@ const subscription = await client.pubsub.subscribe("notifications", {
|
|||||||
subscription.close();
|
subscription.close();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Message Interface:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Message {
|
||||||
|
data: string; // Decoded message payload (string)
|
||||||
|
topic: string; // Topic name
|
||||||
|
timestamp: number; // Server timestamp in milliseconds
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Debug Raw Envelopes
|
||||||
|
|
||||||
|
For debugging, you can inspect raw message envelopes before decoding:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const subscription = await client.pubsub.subscribe("notifications", {
|
||||||
|
onMessage: (msg) => {
|
||||||
|
console.log("Decoded message:", msg.data);
|
||||||
|
},
|
||||||
|
onRaw: (envelope) => {
|
||||||
|
console.log("Raw envelope:", envelope);
|
||||||
|
// { data: "base64...", timestamp: 1234567890, topic: "notifications" }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Multi-Subscriber Support
|
||||||
|
|
||||||
|
Multiple subscriptions to the same topic are supported. Each receives its own copy of messages:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// First subscriber
|
||||||
|
const sub1 = await client.pubsub.subscribe("events", {
|
||||||
|
onMessage: (msg) => console.log("Sub1:", msg.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second subscriber (both receive messages)
|
||||||
|
const sub2 = await client.pubsub.subscribe("events", {
|
||||||
|
onMessage: (msg) => console.log("Sub2:", msg.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unsubscribe independently
|
||||||
|
sub1.close(); // sub2 still active
|
||||||
|
sub2.close(); // fully unsubscribed
|
||||||
|
```
|
||||||
|
|
||||||
#### List Topics
|
#### List Topics
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@ -211,6 +272,40 @@ peers.forEach((peer) => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Proxy Requests Through Anyone Network
|
||||||
|
|
||||||
|
Make anonymous HTTP requests through the Anyone network:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Simple GET request
|
||||||
|
const response = await client.network.proxyAnon({
|
||||||
|
url: "https://api.example.com/data",
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(response.status_code); // 200
|
||||||
|
console.log(response.body); // Response data as string
|
||||||
|
console.log(response.headers); // Response headers
|
||||||
|
|
||||||
|
// POST request with body
|
||||||
|
const postResponse = await client.network.proxyAnon({
|
||||||
|
url: "https://api.example.com/submit",
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ key: "value" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse JSON response
|
||||||
|
const data = JSON.parse(postResponse.body);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** The proxy endpoint requires authentication (API key or JWT) and only works when the Anyone relay is running on the gateway server.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### ClientConfig
|
### ClientConfig
|
||||||
@ -234,7 +329,7 @@ interface ClientConfig {
|
|||||||
By default, credentials are stored in memory. For browser apps, use localStorage:
|
By default, credentials are stored in memory. For browser apps, use localStorage:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { createClient, LocalStorageAdapter } from "@network/sdk";
|
import { createClient, LocalStorageAdapter } from "@debros/network-ts-sdk";
|
||||||
|
|
||||||
const client = createClient({
|
const client = createClient({
|
||||||
baseURL: "http://localhost:6001",
|
baseURL: "http://localhost:6001",
|
||||||
@ -248,7 +343,7 @@ const client = createClient({
|
|||||||
The SDK throws `SDKError` for all errors:
|
The SDK throws `SDKError` for all errors:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SDKError } from "@network/sdk";
|
import { SDKError } from "@debros/network-ts-sdk";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.db.query("SELECT * FROM nonexistent");
|
await client.db.query("SELECT * FROM nonexistent");
|
||||||
@ -268,7 +363,7 @@ The SDK works in browsers with minimal setup:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Browser example
|
// Browser example
|
||||||
import { createClient } from "@network/sdk";
|
import { createClient } from "@debros/network-ts-sdk";
|
||||||
|
|
||||||
const client = createClient({
|
const client = createClient({
|
||||||
baseURL: "https://gateway.example.com",
|
baseURL: "https://gateway.example.com",
|
||||||
|
|||||||
15
package.json
15
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "network-ts-sdk",
|
"name": "@debros/network-ts-sdk",
|
||||||
"version": "0.1.1",
|
"version": "0.3.4",
|
||||||
"description": "TypeScript SDK for DeBros Network Gateway",
|
"description": "TypeScript SDK for DeBros Network Gateway",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
@ -36,12 +36,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"typescript": "^5.3.0",
|
|
||||||
"tsup": "^8.0.0",
|
|
||||||
"vitest": "^1.0.0",
|
|
||||||
"eslint": "^8.0.0",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
"@typescript-eslint/parser": "^6.0.0"
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"eslint": "^8.0.0",
|
||||||
|
"tsup": "^8.0.0",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
"vitest": "^1.0.0"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"registry": "https://registry.npmjs.org/",
|
"registry": "https://registry.npmjs.org/",
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@ -21,6 +21,9 @@ importers:
|
|||||||
'@typescript-eslint/parser':
|
'@typescript-eslint/parser':
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.21.0(eslint@8.57.1)(typescript@5.9.3)
|
version: 6.21.0(eslint@8.57.1)(typescript@5.9.3)
|
||||||
|
dotenv:
|
||||||
|
specifier: ^17.2.3
|
||||||
|
version: 17.2.3
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^8.0.0
|
specifier: ^8.0.0
|
||||||
version: 8.57.1
|
version: 8.57.1
|
||||||
@ -744,6 +747,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
|
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
|
dotenv@17.2.3:
|
||||||
|
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
eastasianwidth@0.2.0:
|
eastasianwidth@0.2.0:
|
||||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||||
|
|
||||||
@ -2054,6 +2061,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
esutils: 2.0.3
|
esutils: 2.0.3
|
||||||
|
|
||||||
|
dotenv@17.2.3: {}
|
||||||
|
|
||||||
eastasianwidth@0.2.0: {}
|
eastasianwidth@0.2.0: {}
|
||||||
|
|
||||||
emoji-regex@8.0.0: {}
|
emoji-regex@8.0.0: {}
|
||||||
|
|||||||
@ -1,10 +1,5 @@
|
|||||||
import { HttpClient } from "../core/http";
|
import { HttpClient } from "../core/http";
|
||||||
import {
|
import { AuthConfig, WhoAmI, StorageAdapter, MemoryStorage } from "./types";
|
||||||
AuthConfig,
|
|
||||||
WhoAmI,
|
|
||||||
StorageAdapter,
|
|
||||||
MemoryStorage,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
export class AuthClient {
|
export class AuthClient {
|
||||||
private httpClient: HttpClient;
|
private httpClient: HttpClient;
|
||||||
@ -33,14 +28,14 @@ export class AuthClient {
|
|||||||
|
|
||||||
setApiKey(apiKey: string) {
|
setApiKey(apiKey: string) {
|
||||||
this.currentApiKey = apiKey;
|
this.currentApiKey = apiKey;
|
||||||
this.currentJwt = undefined;
|
// Don't clear JWT - it will be cleared explicitly on logout
|
||||||
this.httpClient.setApiKey(apiKey);
|
this.httpClient.setApiKey(apiKey);
|
||||||
this.storage.set("apiKey", apiKey);
|
this.storage.set("apiKey", apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
setJwt(jwt: string) {
|
setJwt(jwt: string) {
|
||||||
this.currentJwt = jwt;
|
this.currentJwt = jwt;
|
||||||
this.currentApiKey = undefined;
|
// Don't clear API key - keep it as fallback for after logout
|
||||||
this.httpClient.setJwt(jwt);
|
this.httpClient.setJwt(jwt);
|
||||||
this.storage.set("jwt", jwt);
|
this.storage.set("jwt", jwt);
|
||||||
}
|
}
|
||||||
@ -67,6 +62,51 @@ export class AuthClient {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout user and clear JWT, but preserve API key
|
||||||
|
* Use this for user logout in apps where API key is app-level credential
|
||||||
|
*/
|
||||||
|
async logoutUser(): Promise<void> {
|
||||||
|
// Attempt server-side logout if using JWT
|
||||||
|
if (this.currentJwt) {
|
||||||
|
try {
|
||||||
|
await this.httpClient.post("/v1/auth/logout", { all: true });
|
||||||
|
} catch (error) {
|
||||||
|
// Log warning but don't fail - local cleanup is more important
|
||||||
|
console.warn(
|
||||||
|
"Server-side logout failed, continuing with local cleanup:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear JWT only, preserve API key
|
||||||
|
this.currentJwt = undefined;
|
||||||
|
this.httpClient.setJwt(undefined);
|
||||||
|
await this.storage.set("jwt", ""); // Clear JWT from storage
|
||||||
|
|
||||||
|
// Ensure API key is loaded and set as active auth method
|
||||||
|
if (!this.currentApiKey) {
|
||||||
|
// Try to load from storage
|
||||||
|
const storedApiKey = await this.storage.get("apiKey");
|
||||||
|
if (storedApiKey) {
|
||||||
|
this.currentApiKey = storedApiKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore API key as the active auth method
|
||||||
|
if (this.currentApiKey) {
|
||||||
|
this.httpClient.setApiKey(this.currentApiKey);
|
||||||
|
console.log("[Auth] API key restored after user logout");
|
||||||
|
} else {
|
||||||
|
console.warn("[Auth] No API key available after logout");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full logout - clears both JWT and API key
|
||||||
|
* Use this to completely reset authentication state
|
||||||
|
*/
|
||||||
async logout(): Promise<void> {
|
async logout(): Promise<void> {
|
||||||
// Only attempt server-side logout if using JWT
|
// Only attempt server-side logout if using JWT
|
||||||
// API keys don't support server-side logout with all=true
|
// API keys don't support server-side logout with all=true
|
||||||
@ -75,10 +115,13 @@ export class AuthClient {
|
|||||||
await this.httpClient.post("/v1/auth/logout", { all: true });
|
await this.httpClient.post("/v1/auth/logout", { all: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log warning but don't fail - local cleanup is more important
|
// Log warning but don't fail - local cleanup is more important
|
||||||
console.warn('Server-side logout failed, continuing with local cleanup:', error);
|
console.warn(
|
||||||
|
"Server-side logout failed, continuing with local cleanup:",
|
||||||
|
error
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always clear local state
|
// Always clear local state
|
||||||
this.currentApiKey = undefined;
|
this.currentApiKey = undefined;
|
||||||
this.currentJwt = undefined;
|
this.currentJwt = undefined;
|
||||||
@ -94,4 +137,95 @@ export class AuthClient {
|
|||||||
this.httpClient.setJwt(undefined);
|
this.httpClient.setJwt(undefined);
|
||||||
await this.storage.clear();
|
await this.storage.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request a challenge nonce for wallet authentication
|
||||||
|
*/
|
||||||
|
async challenge(params: {
|
||||||
|
wallet: string;
|
||||||
|
purpose?: string;
|
||||||
|
namespace?: string;
|
||||||
|
}): Promise<{
|
||||||
|
nonce: string;
|
||||||
|
wallet: string;
|
||||||
|
namespace: string;
|
||||||
|
expires_at: string;
|
||||||
|
}> {
|
||||||
|
const response = await this.httpClient.post("/v1/auth/challenge", {
|
||||||
|
wallet: params.wallet,
|
||||||
|
purpose: params.purpose || "authentication",
|
||||||
|
namespace: params.namespace || "default",
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify wallet signature and get JWT token
|
||||||
|
*/
|
||||||
|
async verify(params: {
|
||||||
|
wallet: string;
|
||||||
|
nonce: string;
|
||||||
|
signature: string;
|
||||||
|
namespace?: string;
|
||||||
|
chain_type?: "ETH" | "SOL";
|
||||||
|
}): Promise<{
|
||||||
|
access_token: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
subject: string;
|
||||||
|
namespace: string;
|
||||||
|
api_key?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
token_type?: string;
|
||||||
|
}> {
|
||||||
|
const response = await this.httpClient.post("/v1/auth/verify", {
|
||||||
|
wallet: params.wallet,
|
||||||
|
nonce: params.nonce,
|
||||||
|
signature: params.signature,
|
||||||
|
namespace: params.namespace || "default",
|
||||||
|
chain_type: params.chain_type || "ETH",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persist JWT
|
||||||
|
this.setJwt(response.access_token);
|
||||||
|
|
||||||
|
// Persist API key if server provided it (created in verifyHandler)
|
||||||
|
if ((response as any).api_key) {
|
||||||
|
this.setApiKey((response as any).api_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist refresh token if present (optional, for silent renewal)
|
||||||
|
if ((response as any).refresh_token) {
|
||||||
|
await this.storage.set("refreshToken", (response as any).refresh_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get API key for wallet (creates namespace ownership)
|
||||||
|
*/
|
||||||
|
async getApiKey(params: {
|
||||||
|
wallet: string;
|
||||||
|
nonce: string;
|
||||||
|
signature: string;
|
||||||
|
namespace?: string;
|
||||||
|
chain_type?: "ETH" | "SOL";
|
||||||
|
}): Promise<{
|
||||||
|
api_key: string;
|
||||||
|
namespace: string;
|
||||||
|
wallet: string;
|
||||||
|
}> {
|
||||||
|
const response = await this.httpClient.post("/v1/auth/api-key", {
|
||||||
|
wallet: params.wallet,
|
||||||
|
nonce: params.nonce,
|
||||||
|
signature: params.signature,
|
||||||
|
namespace: params.namespace || "default",
|
||||||
|
chain_type: params.chain_type || "ETH",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Automatically set the API key
|
||||||
|
this.setApiKey(response.api_key);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
203
src/cache/client.ts
vendored
Normal file
203
src/cache/client.ts
vendored
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
import { HttpClient } from "../core/http";
|
||||||
|
import { SDKError } from "../errors";
|
||||||
|
|
||||||
|
export interface CacheGetRequest {
|
||||||
|
dmap: string;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheGetResponse {
|
||||||
|
key: string;
|
||||||
|
value: any;
|
||||||
|
dmap: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CachePutRequest {
|
||||||
|
dmap: string;
|
||||||
|
key: string;
|
||||||
|
value: any;
|
||||||
|
ttl?: string; // Duration string like "1h", "30m"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CachePutResponse {
|
||||||
|
status: string;
|
||||||
|
key: string;
|
||||||
|
dmap: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheDeleteRequest {
|
||||||
|
dmap: string;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheDeleteResponse {
|
||||||
|
status: string;
|
||||||
|
key: string;
|
||||||
|
dmap: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheMultiGetRequest {
|
||||||
|
dmap: string;
|
||||||
|
keys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheMultiGetResponse {
|
||||||
|
results: Array<{
|
||||||
|
key: string;
|
||||||
|
value: any;
|
||||||
|
}>;
|
||||||
|
dmap: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheScanRequest {
|
||||||
|
dmap: string;
|
||||||
|
match?: string; // Optional regex pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheScanResponse {
|
||||||
|
keys: string[];
|
||||||
|
count: number;
|
||||||
|
dmap: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheHealthResponse {
|
||||||
|
status: string;
|
||||||
|
service: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CacheClient {
|
||||||
|
private httpClient: HttpClient;
|
||||||
|
|
||||||
|
constructor(httpClient: HttpClient) {
|
||||||
|
this.httpClient = httpClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check cache service health
|
||||||
|
*/
|
||||||
|
async health(): Promise<CacheHealthResponse> {
|
||||||
|
return this.httpClient.get("/v1/cache/health");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a value from cache
|
||||||
|
* Returns null if the key is not found (cache miss/expired), which is normal behavior
|
||||||
|
*/
|
||||||
|
async get(dmap: string, key: string): Promise<CacheGetResponse | null> {
|
||||||
|
try {
|
||||||
|
return await this.httpClient.post<CacheGetResponse>("/v1/cache/get", {
|
||||||
|
dmap,
|
||||||
|
key,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Cache misses (404 or "key not found" messages) are normal behavior - return null instead of throwing
|
||||||
|
if (
|
||||||
|
error instanceof SDKError &&
|
||||||
|
(error.httpStatus === 404 ||
|
||||||
|
(error.httpStatus === 500 &&
|
||||||
|
error.message?.toLowerCase().includes("key not found")))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Re-throw other errors (network issues, server errors, etc.)
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put a value into cache
|
||||||
|
*/
|
||||||
|
async put(
|
||||||
|
dmap: string,
|
||||||
|
key: string,
|
||||||
|
value: any,
|
||||||
|
ttl?: string
|
||||||
|
): Promise<CachePutResponse> {
|
||||||
|
return this.httpClient.post<CachePutResponse>("/v1/cache/put", {
|
||||||
|
dmap,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
ttl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a value from cache
|
||||||
|
*/
|
||||||
|
async delete(dmap: string, key: string): Promise<CacheDeleteResponse> {
|
||||||
|
return this.httpClient.post<CacheDeleteResponse>("/v1/cache/delete", {
|
||||||
|
dmap,
|
||||||
|
key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get multiple values from cache in a single request
|
||||||
|
* Returns a map of key -> value (or null if not found)
|
||||||
|
* Gracefully handles 404 errors (endpoint not implemented) by returning empty results
|
||||||
|
*/
|
||||||
|
async multiGet(
|
||||||
|
dmap: string,
|
||||||
|
keys: string[]
|
||||||
|
): Promise<Map<string, any | null>> {
|
||||||
|
try {
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.httpClient.post<CacheMultiGetResponse>(
|
||||||
|
"/v1/cache/mget",
|
||||||
|
{
|
||||||
|
dmap,
|
||||||
|
keys,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert array to Map
|
||||||
|
const resultMap = new Map<string, any | null>();
|
||||||
|
|
||||||
|
// First, mark all keys as null (cache miss)
|
||||||
|
keys.forEach((key) => {
|
||||||
|
resultMap.set(key, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then, update with found values
|
||||||
|
if (response.results) {
|
||||||
|
response.results.forEach(({ key, value }) => {
|
||||||
|
resultMap.set(key, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultMap;
|
||||||
|
} catch (error) {
|
||||||
|
// Handle 404 errors silently (endpoint not implemented on backend)
|
||||||
|
// This is expected behavior when the backend doesn't support multiGet yet
|
||||||
|
if (error instanceof SDKError && error.httpStatus === 404) {
|
||||||
|
// Return map with all nulls silently - caller can fall back to individual gets
|
||||||
|
const resultMap = new Map<string, any | null>();
|
||||||
|
keys.forEach((key) => {
|
||||||
|
resultMap.set(key, null);
|
||||||
|
});
|
||||||
|
return resultMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log and return empty results for other errors
|
||||||
|
const resultMap = new Map<string, any | null>();
|
||||||
|
keys.forEach((key) => {
|
||||||
|
resultMap.set(key, null);
|
||||||
|
});
|
||||||
|
console.error(`[CacheClient] Error in multiGet for ${dmap}:`, error);
|
||||||
|
return resultMap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan keys in a distributed map, optionally matching a regex pattern
|
||||||
|
*/
|
||||||
|
async scan(dmap: string, match?: string): Promise<CacheScanResponse> {
|
||||||
|
return this.httpClient.post<CacheScanResponse>("/v1/cache/scan", {
|
||||||
|
dmap,
|
||||||
|
match,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
300
src/core/http.ts
300
src/core/http.ts
@ -8,6 +8,29 @@ export interface HttpClientConfig {
|
|||||||
fetch?: typeof fetch;
|
fetch?: typeof fetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a fetch function with proper TLS configuration for staging certificates
|
||||||
|
* In Node.js, we need to configure TLS to accept Let's Encrypt staging certificates
|
||||||
|
*/
|
||||||
|
function createFetchWithTLSConfig(): typeof fetch {
|
||||||
|
// Check if we're in a Node.js environment
|
||||||
|
if (typeof process !== "undefined" && process.versions?.node) {
|
||||||
|
// For testing/staging/development: allow staging certificates
|
||||||
|
// Let's Encrypt staging certificates are self-signed and not trusted by default
|
||||||
|
const isDevelopmentOrStaging =
|
||||||
|
process.env.NODE_ENV !== "production" ||
|
||||||
|
process.env.DEBROS_ALLOW_STAGING_CERTS === "true" ||
|
||||||
|
process.env.DEBROS_USE_HTTPS === "true";
|
||||||
|
|
||||||
|
if (isDevelopmentOrStaging) {
|
||||||
|
// Allow self-signed/staging certificates
|
||||||
|
// WARNING: Only use this in development/testing environments
|
||||||
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return globalThis.fetch;
|
||||||
|
}
|
||||||
|
|
||||||
export class HttpClient {
|
export class HttpClient {
|
||||||
private baseURL: string;
|
private baseURL: string;
|
||||||
private timeout: number;
|
private timeout: number;
|
||||||
@ -19,28 +42,73 @@ export class HttpClient {
|
|||||||
|
|
||||||
constructor(config: HttpClientConfig) {
|
constructor(config: HttpClientConfig) {
|
||||||
this.baseURL = config.baseURL.replace(/\/$/, "");
|
this.baseURL = config.baseURL.replace(/\/$/, "");
|
||||||
this.timeout = config.timeout ?? 30000;
|
this.timeout = config.timeout ?? 60000; // Increased from 30s to 60s for pub/sub operations
|
||||||
this.maxRetries = config.maxRetries ?? 3;
|
this.maxRetries = config.maxRetries ?? 3;
|
||||||
this.retryDelayMs = config.retryDelayMs ?? 1000;
|
this.retryDelayMs = config.retryDelayMs ?? 1000;
|
||||||
this.fetch = config.fetch ?? globalThis.fetch;
|
// Use provided fetch or create one with proper TLS configuration for staging certificates
|
||||||
|
this.fetch = config.fetch ?? createFetchWithTLSConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
setApiKey(apiKey?: string) {
|
setApiKey(apiKey?: string) {
|
||||||
this.apiKey = apiKey;
|
this.apiKey = apiKey;
|
||||||
this.jwt = undefined;
|
// Don't clear JWT - allow both to coexist
|
||||||
}
|
}
|
||||||
|
|
||||||
setJwt(jwt?: string) {
|
setJwt(jwt?: string) {
|
||||||
this.jwt = jwt;
|
this.jwt = jwt;
|
||||||
this.apiKey = undefined;
|
// Don't clear API key - allow both to coexist
|
||||||
|
if (typeof console !== "undefined") {
|
||||||
|
console.log(
|
||||||
|
"[HttpClient] JWT set:",
|
||||||
|
!!jwt,
|
||||||
|
"API key still present:",
|
||||||
|
!!this.apiKey
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getAuthHeaders(): Record<string, string> {
|
private getAuthHeaders(path: string): Record<string, string> {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (this.jwt) {
|
|
||||||
headers["Authorization"] = `Bearer ${this.jwt}`;
|
// For database, pubsub, proxy, and cache operations, ONLY use API key to avoid JWT user context
|
||||||
} else if (this.apiKey) {
|
// interfering with namespace-level authorization
|
||||||
headers["X-API-Key"] = this.apiKey;
|
const isDbOperation = path.includes("/v1/rqlite/");
|
||||||
|
const isPubSubOperation = path.includes("/v1/pubsub/");
|
||||||
|
const isProxyOperation = path.includes("/v1/proxy/");
|
||||||
|
const isCacheOperation = path.includes("/v1/cache/");
|
||||||
|
|
||||||
|
// For auth operations, prefer API key over JWT to ensure proper authentication
|
||||||
|
const isAuthOperation = path.includes("/v1/auth/");
|
||||||
|
|
||||||
|
if (
|
||||||
|
isDbOperation ||
|
||||||
|
isPubSubOperation ||
|
||||||
|
isProxyOperation ||
|
||||||
|
isCacheOperation
|
||||||
|
) {
|
||||||
|
// For database/pubsub/proxy/cache operations: use only API key (preferred for namespace operations)
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
} else if (isAuthOperation) {
|
||||||
|
// For auth operations: prefer API key over JWT (auth endpoints should use explicit API key)
|
||||||
|
if (this.apiKey) {
|
||||||
|
headers["X-API-Key"] = this.apiKey;
|
||||||
|
}
|
||||||
|
if (this.jwt) {
|
||||||
|
headers["Authorization"] = `Bearer ${this.jwt}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For other operations: send both JWT and API key
|
||||||
|
if (this.jwt) {
|
||||||
|
headers["Authorization"] = `Bearer ${this.jwt}`;
|
||||||
|
}
|
||||||
|
if (this.apiKey) {
|
||||||
|
headers["X-API-Key"] = this.apiKey;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
@ -49,6 +117,10 @@ export class HttpClient {
|
|||||||
return this.jwt || this.apiKey;
|
return this.jwt || this.apiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getApiKey(): string | undefined {
|
||||||
|
return this.apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
async request<T = any>(
|
async request<T = any>(
|
||||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||||
path: string,
|
path: string,
|
||||||
@ -56,8 +128,10 @@ export class HttpClient {
|
|||||||
body?: any;
|
body?: any;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
query?: Record<string, string | number | boolean>;
|
query?: Record<string, string | number | boolean>;
|
||||||
|
timeout?: number; // Per-request timeout override
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
const startTime = performance.now(); // Track request start time
|
||||||
const url = new URL(this.baseURL + path);
|
const url = new URL(this.baseURL + path);
|
||||||
if (options.query) {
|
if (options.query) {
|
||||||
Object.entries(options.query).forEach(([key, value]) => {
|
Object.entries(options.query).forEach(([key, value]) => {
|
||||||
@ -67,27 +141,127 @@ export class HttpClient {
|
|||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...this.getAuthHeaders(),
|
...this.getAuthHeaders(path),
|
||||||
...options.headers,
|
...options.headers,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const requestTimeout = options.timeout ?? this.timeout; // Use override or default
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
|
||||||
|
|
||||||
const fetchOptions: RequestInit = {
|
const fetchOptions: RequestInit = {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
signal: AbortSignal.timeout(this.timeout),
|
signal: controller.signal,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (options.body !== undefined) {
|
if (options.body !== undefined) {
|
||||||
fetchOptions.body = JSON.stringify(options.body);
|
fetchOptions.body = JSON.stringify(options.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.requestWithRetry(url.toString(), fetchOptions);
|
// Extract and log SQL query details for rqlite operations
|
||||||
|
const isRqliteOperation = path.includes("/v1/rqlite/");
|
||||||
|
let queryDetails: string | null = null;
|
||||||
|
if (isRqliteOperation && options.body) {
|
||||||
|
try {
|
||||||
|
const body =
|
||||||
|
typeof options.body === "string"
|
||||||
|
? JSON.parse(options.body)
|
||||||
|
: options.body;
|
||||||
|
|
||||||
|
if (body.sql) {
|
||||||
|
// Direct SQL query (query/exec endpoints)
|
||||||
|
queryDetails = `SQL: ${body.sql}`;
|
||||||
|
if (body.args && body.args.length > 0) {
|
||||||
|
queryDetails += ` | Args: [${body.args
|
||||||
|
.map((a: any) => (typeof a === "string" ? `"${a}"` : a))
|
||||||
|
.join(", ")}]`;
|
||||||
|
}
|
||||||
|
} else if (body.table) {
|
||||||
|
// Table-based query (find/find-one/select endpoints)
|
||||||
|
queryDetails = `Table: ${body.table}`;
|
||||||
|
if (body.criteria && Object.keys(body.criteria).length > 0) {
|
||||||
|
queryDetails += ` | Criteria: ${JSON.stringify(body.criteria)}`;
|
||||||
|
}
|
||||||
|
if (body.options) {
|
||||||
|
queryDetails += ` | Options: ${JSON.stringify(body.options)}`;
|
||||||
|
}
|
||||||
|
if (body.select) {
|
||||||
|
queryDetails += ` | Select: ${JSON.stringify(body.select)}`;
|
||||||
|
}
|
||||||
|
if (body.where) {
|
||||||
|
queryDetails += ` | Where: ${JSON.stringify(body.where)}`;
|
||||||
|
}
|
||||||
|
if (body.limit) {
|
||||||
|
queryDetails += ` | Limit: ${body.limit}`;
|
||||||
|
}
|
||||||
|
if (body.offset) {
|
||||||
|
queryDetails += ` | Offset: ${body.offset}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Failed to parse body, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.requestWithRetry(
|
||||||
|
url.toString(),
|
||||||
|
fetchOptions,
|
||||||
|
0,
|
||||||
|
startTime
|
||||||
|
);
|
||||||
|
const duration = performance.now() - startTime;
|
||||||
|
if (typeof console !== "undefined") {
|
||||||
|
const logMessage = `[HttpClient] ${method} ${path} completed in ${duration.toFixed(
|
||||||
|
2
|
||||||
|
)}ms`;
|
||||||
|
if (queryDetails) {
|
||||||
|
console.log(logMessage);
|
||||||
|
console.log(`[HttpClient] ${queryDetails}`);
|
||||||
|
} else {
|
||||||
|
console.log(logMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const duration = performance.now() - startTime;
|
||||||
|
if (typeof console !== "undefined") {
|
||||||
|
// For 404 errors on find-one calls, log at warn level (not error) since "not found" is expected
|
||||||
|
// Application layer handles these cases in try-catch blocks
|
||||||
|
const is404FindOne =
|
||||||
|
path === "/v1/rqlite/find-one" &&
|
||||||
|
error instanceof SDKError &&
|
||||||
|
error.httpStatus === 404;
|
||||||
|
|
||||||
|
if (is404FindOne) {
|
||||||
|
// Log as warning for visibility, but not as error since it's expected behavior
|
||||||
|
console.warn(
|
||||||
|
`[HttpClient] ${method} ${path} returned 404 after ${duration.toFixed(
|
||||||
|
2
|
||||||
|
)}ms (expected for optional lookups)`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const errorMessage = `[HttpClient] ${method} ${path} failed after ${duration.toFixed(
|
||||||
|
2
|
||||||
|
)}ms:`;
|
||||||
|
console.error(errorMessage, error);
|
||||||
|
if (queryDetails) {
|
||||||
|
console.error(`[HttpClient] ${queryDetails}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async requestWithRetry(
|
private async requestWithRetry(
|
||||||
url: string,
|
url: string,
|
||||||
options: RequestInit,
|
options: RequestInit,
|
||||||
attempt: number = 0
|
attempt: number = 0,
|
||||||
|
startTime?: number // Track start time for timing across retries
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await this.fetch(url, options);
|
const response = await this.fetch(url, options);
|
||||||
@ -116,7 +290,7 @@ export class HttpClient {
|
|||||||
await new Promise((resolve) =>
|
await new Promise((resolve) =>
|
||||||
setTimeout(resolve, this.retryDelayMs * (attempt + 1))
|
setTimeout(resolve, this.retryDelayMs * (attempt + 1))
|
||||||
);
|
);
|
||||||
return this.requestWithRetry(url, options, attempt + 1);
|
return this.requestWithRetry(url, options, attempt + 1, startTime);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -152,6 +326,104 @@ export class HttpClient {
|
|||||||
return this.request<T>("DELETE", path, options);
|
return this.request<T>("DELETE", path, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file using multipart/form-data
|
||||||
|
* This is a special method for file uploads that bypasses JSON serialization
|
||||||
|
*/
|
||||||
|
async uploadFile<T = any>(
|
||||||
|
path: string,
|
||||||
|
formData: FormData,
|
||||||
|
options?: {
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
): Promise<T> {
|
||||||
|
const startTime = performance.now(); // Track upload start time
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const requestTimeout = options?.timeout ?? this.timeout * 5; // 5x timeout for uploads
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
signal: controller.signal,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.requestWithRetry(
|
||||||
|
url.toString(),
|
||||||
|
fetchOptions,
|
||||||
|
0,
|
||||||
|
startTime
|
||||||
|
);
|
||||||
|
const duration = performance.now() - startTime;
|
||||||
|
if (typeof console !== "undefined") {
|
||||||
|
console.log(
|
||||||
|
`[HttpClient] POST ${path} (upload) completed in ${duration.toFixed(
|
||||||
|
2
|
||||||
|
)}ms`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const duration = performance.now() - startTime;
|
||||||
|
if (typeof console !== "undefined") {
|
||||||
|
console.error(
|
||||||
|
`[HttpClient] POST ${path} (upload) failed after ${duration.toFixed(
|
||||||
|
2
|
||||||
|
)}ms:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a binary response (returns Response object for streaming)
|
||||||
|
*/
|
||||||
|
async getBinary(path: string): Promise<Response> {
|
||||||
|
const url = new URL(this.baseURL + path);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
...this.getAuthHeaders(path),
|
||||||
|
};
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), this.timeout * 5); // 5x timeout for downloads
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method: "GET",
|
||||||
|
headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.fetch(url.toString(), fetchOptions);
|
||||||
|
if (!response.ok) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
const error = await response.json().catch(() => ({
|
||||||
|
error: response.statusText,
|
||||||
|
}));
|
||||||
|
throw SDKError.fromResponse(response.status, error);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (error instanceof SDKError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getToken(): string | undefined {
|
getToken(): string | undefined {
|
||||||
return this.getAuthToken();
|
return this.getAuthToken();
|
||||||
}
|
}
|
||||||
|
|||||||
167
src/core/ws.ts
167
src/core/ws.ts
@ -4,10 +4,6 @@ import { SDKError } from "../errors";
|
|||||||
export interface WSClientConfig {
|
export interface WSClientConfig {
|
||||||
wsURL: string;
|
wsURL: string;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
maxReconnectAttempts?: number;
|
|
||||||
reconnectDelayMs?: number;
|
|
||||||
heartbeatIntervalMs?: number;
|
|
||||||
authMode?: "header" | "query";
|
|
||||||
authToken?: string;
|
authToken?: string;
|
||||||
WebSocket?: typeof WebSocket;
|
WebSocket?: typeof WebSocket;
|
||||||
}
|
}
|
||||||
@ -15,54 +11,53 @@ export interface WSClientConfig {
|
|||||||
export type WSMessageHandler = (data: string) => void;
|
export type WSMessageHandler = (data: string) => void;
|
||||||
export type WSErrorHandler = (error: Error) => void;
|
export type WSErrorHandler = (error: Error) => void;
|
||||||
export type WSCloseHandler = () => void;
|
export type WSCloseHandler = () => void;
|
||||||
|
export type WSOpenHandler = () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple WebSocket client with minimal abstractions
|
||||||
|
* No complex reconnection, no heartbeats - keep it simple
|
||||||
|
*/
|
||||||
export class WSClient {
|
export class WSClient {
|
||||||
private url: string;
|
private url: string;
|
||||||
private timeout: number;
|
private timeout: number;
|
||||||
private maxReconnectAttempts: number;
|
|
||||||
private reconnectDelayMs: number;
|
|
||||||
private heartbeatIntervalMs: number;
|
|
||||||
private authMode: "header" | "query";
|
|
||||||
private authToken?: string;
|
private authToken?: string;
|
||||||
private WebSocketClass: typeof WebSocket;
|
private WebSocketClass: typeof WebSocket;
|
||||||
|
|
||||||
private ws?: WebSocket;
|
private ws?: WebSocket;
|
||||||
private reconnectAttempts = 0;
|
|
||||||
private heartbeatInterval?: NodeJS.Timeout;
|
|
||||||
private messageHandlers: Set<WSMessageHandler> = new Set();
|
private messageHandlers: Set<WSMessageHandler> = new Set();
|
||||||
private errorHandlers: Set<WSErrorHandler> = new Set();
|
private errorHandlers: Set<WSErrorHandler> = new Set();
|
||||||
private closeHandlers: Set<WSCloseHandler> = new Set();
|
private closeHandlers: Set<WSCloseHandler> = new Set();
|
||||||
private isManuallyClosed = false;
|
private openHandlers: Set<WSOpenHandler> = new Set();
|
||||||
|
private isClosed = false;
|
||||||
|
|
||||||
constructor(config: WSClientConfig) {
|
constructor(config: WSClientConfig) {
|
||||||
this.url = config.wsURL;
|
this.url = config.wsURL;
|
||||||
this.timeout = config.timeout ?? 30000;
|
this.timeout = config.timeout ?? 30000;
|
||||||
this.maxReconnectAttempts = config.maxReconnectAttempts ?? 5;
|
|
||||||
this.reconnectDelayMs = config.reconnectDelayMs ?? 1000;
|
|
||||||
this.heartbeatIntervalMs = config.heartbeatIntervalMs ?? 30000;
|
|
||||||
this.authMode = config.authMode ?? "header";
|
|
||||||
this.authToken = config.authToken;
|
this.authToken = config.authToken;
|
||||||
this.WebSocketClass = config.WebSocket ?? WebSocket;
|
this.WebSocketClass = config.WebSocket ?? WebSocket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to WebSocket server
|
||||||
|
*/
|
||||||
connect(): Promise<void> {
|
connect(): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const wsUrl = this.buildWSUrl();
|
const wsUrl = this.buildWSUrl();
|
||||||
this.ws = new this.WebSocketClass(wsUrl);
|
this.ws = new this.WebSocketClass(wsUrl);
|
||||||
|
this.isClosed = false;
|
||||||
// Note: Custom headers via ws library in Node.js are not sent with WebSocket upgrade requests
|
|
||||||
// so we rely on query parameters for authentication
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
this.ws?.close();
|
this.ws?.close();
|
||||||
reject(new SDKError("WebSocket connection timeout", 408, "WS_TIMEOUT"));
|
reject(
|
||||||
|
new SDKError("WebSocket connection timeout", 408, "WS_TIMEOUT")
|
||||||
|
);
|
||||||
}, this.timeout);
|
}, this.timeout);
|
||||||
|
|
||||||
this.ws.addEventListener("open", () => {
|
this.ws.addEventListener("open", () => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
this.reconnectAttempts = 0;
|
console.log("[WSClient] Connected to", this.url);
|
||||||
this.startHeartbeat();
|
this.openHandlers.forEach((handler) => handler());
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -72,24 +67,16 @@ export class WSClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.ws.addEventListener("error", (event: Event) => {
|
this.ws.addEventListener("error", (event: Event) => {
|
||||||
|
console.error("[WSClient] WebSocket error:", event);
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
const error = new SDKError(
|
const error = new SDKError("WebSocket error", 500, "WS_ERROR", event);
|
||||||
"WebSocket error",
|
|
||||||
500,
|
|
||||||
"WS_ERROR",
|
|
||||||
event
|
|
||||||
);
|
|
||||||
this.errorHandlers.forEach((handler) => handler(error));
|
this.errorHandlers.forEach((handler) => handler(error));
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ws.addEventListener("close", () => {
|
this.ws.addEventListener("close", () => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
this.stopHeartbeat();
|
console.log("[WSClient] Connection closed");
|
||||||
if (!this.isManuallyClosed) {
|
this.closeHandlers.forEach((handler) => handler());
|
||||||
this.attemptReconnect();
|
|
||||||
} else {
|
|
||||||
this.closeHandlers.forEach((handler) => handler());
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
@ -97,86 +84,106 @@ export class WSClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build WebSocket URL with auth token
|
||||||
|
*/
|
||||||
private buildWSUrl(): string {
|
private buildWSUrl(): string {
|
||||||
let url = this.url;
|
let url = this.url;
|
||||||
|
|
||||||
// Always append auth token as query parameter for compatibility
|
|
||||||
// Works in both Node.js and browser environments
|
|
||||||
if (this.authToken) {
|
if (this.authToken) {
|
||||||
const separator = url.includes("?") ? "&" : "?";
|
const separator = url.includes("?") ? "&" : "?";
|
||||||
const paramName = this.authToken.startsWith("ak_") ? "api_key" : "token";
|
const paramName = this.authToken.startsWith("ak_") ? "api_key" : "token";
|
||||||
url += `${separator}${paramName}=${encodeURIComponent(this.authToken)}`;
|
url += `${separator}${paramName}=${encodeURIComponent(this.authToken)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
private startHeartbeat() {
|
/**
|
||||||
this.heartbeatInterval = setInterval(() => {
|
* Register message handler
|
||||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
*/
|
||||||
this.ws.send(JSON.stringify({ type: "ping" }));
|
onMessage(handler: WSMessageHandler): () => void {
|
||||||
}
|
|
||||||
}, this.heartbeatIntervalMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopHeartbeat() {
|
|
||||||
if (this.heartbeatInterval) {
|
|
||||||
clearInterval(this.heartbeatInterval);
|
|
||||||
this.heartbeatInterval = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private attemptReconnect() {
|
|
||||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
||||||
this.reconnectAttempts++;
|
|
||||||
const delayMs = this.reconnectDelayMs * this.reconnectAttempts;
|
|
||||||
setTimeout(() => {
|
|
||||||
this.connect().catch((error) => {
|
|
||||||
this.errorHandlers.forEach((handler) => handler(error));
|
|
||||||
});
|
|
||||||
}, delayMs);
|
|
||||||
} else {
|
|
||||||
this.closeHandlers.forEach((handler) => handler());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMessage(handler: WSMessageHandler) {
|
|
||||||
this.messageHandlers.add(handler);
|
this.messageHandlers.add(handler);
|
||||||
return () => this.messageHandlers.delete(handler);
|
return () => this.messageHandlers.delete(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
onError(handler: WSErrorHandler) {
|
/**
|
||||||
|
* Unregister message handler
|
||||||
|
*/
|
||||||
|
offMessage(handler: WSMessageHandler): void {
|
||||||
|
this.messageHandlers.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register error handler
|
||||||
|
*/
|
||||||
|
onError(handler: WSErrorHandler): () => void {
|
||||||
this.errorHandlers.add(handler);
|
this.errorHandlers.add(handler);
|
||||||
return () => this.errorHandlers.delete(handler);
|
return () => this.errorHandlers.delete(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
onClose(handler: WSCloseHandler) {
|
/**
|
||||||
|
* Unregister error handler
|
||||||
|
*/
|
||||||
|
offError(handler: WSErrorHandler): void {
|
||||||
|
this.errorHandlers.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register close handler
|
||||||
|
*/
|
||||||
|
onClose(handler: WSCloseHandler): () => void {
|
||||||
this.closeHandlers.add(handler);
|
this.closeHandlers.add(handler);
|
||||||
return () => this.closeHandlers.delete(handler);
|
return () => this.closeHandlers.delete(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
send(data: string) {
|
/**
|
||||||
|
* Unregister close handler
|
||||||
|
*/
|
||||||
|
offClose(handler: WSCloseHandler): void {
|
||||||
|
this.closeHandlers.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register open handler
|
||||||
|
*/
|
||||||
|
onOpen(handler: WSOpenHandler): () => void {
|
||||||
|
this.openHandlers.add(handler);
|
||||||
|
return () => this.openHandlers.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send data through WebSocket
|
||||||
|
*/
|
||||||
|
send(data: string): void {
|
||||||
if (this.ws?.readyState !== WebSocket.OPEN) {
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
||||||
throw new SDKError(
|
throw new SDKError("WebSocket is not connected", 500, "WS_NOT_CONNECTED");
|
||||||
"WebSocket is not connected",
|
|
||||||
500,
|
|
||||||
"WS_NOT_CONNECTED"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
this.ws.send(data);
|
this.ws.send(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
/**
|
||||||
this.isManuallyClosed = true;
|
* Close WebSocket connection
|
||||||
this.stopHeartbeat();
|
*/
|
||||||
|
close(): void {
|
||||||
|
if (this.isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.isClosed = true;
|
||||||
this.ws?.close();
|
this.ws?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if WebSocket is connected
|
||||||
|
*/
|
||||||
isConnected(): boolean {
|
isConnected(): boolean {
|
||||||
return this.ws?.readyState === WebSocket.OPEN;
|
return !this.isClosed && this.ws?.readyState === WebSocket.OPEN;
|
||||||
}
|
}
|
||||||
|
|
||||||
setAuthToken(token?: string) {
|
/**
|
||||||
|
* Update auth token
|
||||||
|
*/
|
||||||
|
setAuthToken(token?: string): void {
|
||||||
this.authToken = token;
|
this.authToken = token;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -98,7 +98,9 @@ export class Repository<T extends Record<string, any>> {
|
|||||||
private buildInsertSql(entity: T): string {
|
private buildInsertSql(entity: T): string {
|
||||||
const columns = Object.keys(entity).filter((k) => entity[k] !== undefined);
|
const columns = Object.keys(entity).filter((k) => entity[k] !== undefined);
|
||||||
const placeholders = columns.map(() => "?").join(", ");
|
const placeholders = columns.map(() => "?").join(", ");
|
||||||
return `INSERT INTO ${this.tableName} (${columns.join(", ")}) VALUES (${placeholders})`;
|
return `INSERT INTO ${this.tableName} (${columns.join(
|
||||||
|
", "
|
||||||
|
)}) VALUES (${placeholders})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildInsertArgs(entity: T): any[] {
|
private buildInsertArgs(entity: T): any[] {
|
||||||
@ -111,7 +113,9 @@ export class Repository<T extends Record<string, any>> {
|
|||||||
const columns = Object.keys(entity)
|
const columns = Object.keys(entity)
|
||||||
.filter((k) => entity[k] !== undefined && k !== this.primaryKey)
|
.filter((k) => entity[k] !== undefined && k !== this.primaryKey)
|
||||||
.map((k) => `${k} = ?`);
|
.map((k) => `${k} = ?`);
|
||||||
return `UPDATE ${this.tableName} SET ${columns.join(", ")} WHERE ${this.primaryKey} = ?`;
|
return `UPDATE ${this.tableName} SET ${columns.join(", ")} WHERE ${
|
||||||
|
this.primaryKey
|
||||||
|
} = ?`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildUpdateArgs(entity: T): any[] {
|
private buildUpdateArgs(entity: T): any[] {
|
||||||
|
|||||||
54
src/index.ts
54
src/index.ts
@ -3,8 +3,14 @@ import { AuthClient } from "./auth/client";
|
|||||||
import { DBClient } from "./db/client";
|
import { DBClient } from "./db/client";
|
||||||
import { PubSubClient } from "./pubsub/client";
|
import { PubSubClient } from "./pubsub/client";
|
||||||
import { NetworkClient } from "./network/client";
|
import { NetworkClient } from "./network/client";
|
||||||
|
import { CacheClient } from "./cache/client";
|
||||||
|
import { StorageClient } from "./storage/client";
|
||||||
import { WSClientConfig } from "./core/ws";
|
import { WSClientConfig } from "./core/ws";
|
||||||
import { StorageAdapter, MemoryStorage, LocalStorageAdapter } from "./auth/types";
|
import {
|
||||||
|
StorageAdapter,
|
||||||
|
MemoryStorage,
|
||||||
|
LocalStorageAdapter,
|
||||||
|
} from "./auth/types";
|
||||||
|
|
||||||
export interface ClientConfig extends Omit<HttpClientConfig, "fetch"> {
|
export interface ClientConfig extends Omit<HttpClientConfig, "fetch"> {
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
@ -19,6 +25,8 @@ export interface Client {
|
|||||||
db: DBClient;
|
db: DBClient;
|
||||||
pubsub: PubSubClient;
|
pubsub: PubSubClient;
|
||||||
network: NetworkClient;
|
network: NetworkClient;
|
||||||
|
cache: CacheClient;
|
||||||
|
storage: StorageClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createClient(config: ClientConfig): Client {
|
export function createClient(config: ClientConfig): Client {
|
||||||
@ -38,8 +46,9 @@ export function createClient(config: ClientConfig): Client {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Derive WebSocket URL from baseURL if not explicitly provided
|
// Derive WebSocket URL from baseURL if not explicitly provided
|
||||||
const wsURL = config.wsConfig?.wsURL ??
|
const wsURL =
|
||||||
config.baseURL.replace(/^http/, 'ws').replace(/\/$/, '');
|
config.wsConfig?.wsURL ??
|
||||||
|
config.baseURL.replace(/^http/, "ws").replace(/\/$/, "");
|
||||||
|
|
||||||
const db = new DBClient(httpClient);
|
const db = new DBClient(httpClient);
|
||||||
const pubsub = new PubSubClient(httpClient, {
|
const pubsub = new PubSubClient(httpClient, {
|
||||||
@ -47,16 +56,19 @@ export function createClient(config: ClientConfig): Client {
|
|||||||
wsURL,
|
wsURL,
|
||||||
});
|
});
|
||||||
const network = new NetworkClient(httpClient);
|
const network = new NetworkClient(httpClient);
|
||||||
|
const cache = new CacheClient(httpClient);
|
||||||
|
const storage = new StorageClient(httpClient);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
auth,
|
auth,
|
||||||
db,
|
db,
|
||||||
pubsub,
|
pubsub,
|
||||||
network,
|
network,
|
||||||
|
cache,
|
||||||
|
storage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-exports
|
|
||||||
export { HttpClient } from "./core/http";
|
export { HttpClient } from "./core/http";
|
||||||
export { WSClient } from "./core/ws";
|
export { WSClient } from "./core/ws";
|
||||||
export { AuthClient } from "./auth/client";
|
export { AuthClient } from "./auth/client";
|
||||||
@ -65,13 +77,11 @@ export { QueryBuilder } from "./db/qb";
|
|||||||
export { Repository } from "./db/repository";
|
export { Repository } from "./db/repository";
|
||||||
export { PubSubClient, Subscription } from "./pubsub/client";
|
export { PubSubClient, Subscription } from "./pubsub/client";
|
||||||
export { NetworkClient } from "./network/client";
|
export { NetworkClient } from "./network/client";
|
||||||
|
export { CacheClient } from "./cache/client";
|
||||||
|
export { StorageClient } from "./storage/client";
|
||||||
export { SDKError } from "./errors";
|
export { SDKError } from "./errors";
|
||||||
export { MemoryStorage, LocalStorageAdapter } from "./auth/types";
|
export { MemoryStorage, LocalStorageAdapter } from "./auth/types";
|
||||||
export type {
|
export type { StorageAdapter, AuthConfig, WhoAmI } from "./auth/types";
|
||||||
StorageAdapter,
|
|
||||||
AuthConfig,
|
|
||||||
WhoAmI,
|
|
||||||
} from "./auth/types";
|
|
||||||
export type * from "./db/types";
|
export type * from "./db/types";
|
||||||
export type {
|
export type {
|
||||||
Message,
|
Message,
|
||||||
@ -79,4 +89,28 @@ export type {
|
|||||||
ErrorHandler,
|
ErrorHandler,
|
||||||
CloseHandler,
|
CloseHandler,
|
||||||
} from "./pubsub/client";
|
} from "./pubsub/client";
|
||||||
export type { PeerInfo, NetworkStatus } from "./network/client";
|
export type {
|
||||||
|
PeerInfo,
|
||||||
|
NetworkStatus,
|
||||||
|
ProxyRequest,
|
||||||
|
ProxyResponse,
|
||||||
|
} from "./network/client";
|
||||||
|
export type {
|
||||||
|
CacheGetRequest,
|
||||||
|
CacheGetResponse,
|
||||||
|
CachePutRequest,
|
||||||
|
CachePutResponse,
|
||||||
|
CacheDeleteRequest,
|
||||||
|
CacheDeleteResponse,
|
||||||
|
CacheMultiGetRequest,
|
||||||
|
CacheMultiGetResponse,
|
||||||
|
CacheScanRequest,
|
||||||
|
CacheScanResponse,
|
||||||
|
CacheHealthResponse,
|
||||||
|
} from "./cache/client";
|
||||||
|
export type {
|
||||||
|
StorageUploadResponse,
|
||||||
|
StoragePinRequest,
|
||||||
|
StoragePinResponse,
|
||||||
|
StorageStatus,
|
||||||
|
} from "./storage/client";
|
||||||
|
|||||||
@ -7,9 +7,25 @@ export interface PeerInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface NetworkStatus {
|
export interface NetworkStatus {
|
||||||
healthy: boolean;
|
node_id: string;
|
||||||
peers: number;
|
connected: boolean;
|
||||||
uptime?: number;
|
peer_count: number;
|
||||||
|
database_size: number;
|
||||||
|
uptime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyRequest {
|
||||||
|
url: string;
|
||||||
|
method: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyResponse {
|
||||||
|
status_code: number;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: string;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NetworkClient {
|
export class NetworkClient {
|
||||||
@ -35,7 +51,9 @@ export class NetworkClient {
|
|||||||
* Get network status.
|
* Get network status.
|
||||||
*/
|
*/
|
||||||
async status(): Promise<NetworkStatus> {
|
async status(): Promise<NetworkStatus> {
|
||||||
const response = await this.httpClient.get<NetworkStatus>("/v1/status");
|
const response = await this.httpClient.get<NetworkStatus>(
|
||||||
|
"/v1/network/status"
|
||||||
|
);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,4 +80,40 @@ export class NetworkClient {
|
|||||||
async disconnect(peerId: string): Promise<void> {
|
async disconnect(peerId: string): Promise<void> {
|
||||||
await this.httpClient.post("/v1/network/disconnect", { peer_id: peerId });
|
await this.httpClient.post("/v1/network/disconnect", { peer_id: peerId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy an HTTP request through the Anyone network.
|
||||||
|
* Requires authentication (API key or JWT).
|
||||||
|
*
|
||||||
|
* @param request - The proxy request configuration
|
||||||
|
* @returns The proxied response
|
||||||
|
* @throws {SDKError} If the Anyone proxy is not available or the request fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const response = await client.network.proxyAnon({
|
||||||
|
* url: 'https://api.example.com/data',
|
||||||
|
* method: 'GET',
|
||||||
|
* headers: {
|
||||||
|
* 'Accept': 'application/json'
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* console.log(response.status_code); // 200
|
||||||
|
* console.log(response.body); // Response data
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
async proxyAnon(request: ProxyRequest): Promise<ProxyResponse> {
|
||||||
|
const response = await this.httpClient.post<ProxyResponse>(
|
||||||
|
"/v1/proxy/anon",
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if the response contains an error
|
||||||
|
if (response.error) {
|
||||||
|
throw new Error(`Proxy request failed: ${response.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,13 +4,64 @@ import { WSClient, WSClientConfig } from "../core/ws";
|
|||||||
export interface Message {
|
export interface Message {
|
||||||
data: string;
|
data: string;
|
||||||
topic: string;
|
topic: string;
|
||||||
timestamp?: number;
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawEnvelope {
|
||||||
|
data: string; // base64-encoded
|
||||||
|
timestamp: number;
|
||||||
|
topic: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-platform base64 encoding/decoding utilities
|
||||||
|
function base64Encode(str: string): string {
|
||||||
|
if (typeof Buffer !== "undefined") {
|
||||||
|
return Buffer.from(str).toString("base64");
|
||||||
|
} else if (typeof btoa !== "undefined") {
|
||||||
|
return btoa(
|
||||||
|
encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) =>
|
||||||
|
String.fromCharCode(parseInt(p1, 16))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error("No base64 encoding method available");
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64EncodeBytes(bytes: Uint8Array): string {
|
||||||
|
if (typeof Buffer !== "undefined") {
|
||||||
|
return Buffer.from(bytes).toString("base64");
|
||||||
|
} else if (typeof btoa !== "undefined") {
|
||||||
|
let binary = "";
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
throw new Error("No base64 encoding method available");
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64Decode(b64: string): string {
|
||||||
|
if (typeof Buffer !== "undefined") {
|
||||||
|
return Buffer.from(b64, "base64").toString("utf-8");
|
||||||
|
} else if (typeof atob !== "undefined") {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
throw new Error("No base64 decoding method available");
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MessageHandler = (message: Message) => void;
|
export type MessageHandler = (message: Message) => void;
|
||||||
export type ErrorHandler = (error: Error) => void;
|
export type ErrorHandler = (error: Error) => void;
|
||||||
export type CloseHandler = () => void;
|
export type CloseHandler = () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple PubSub client - one WebSocket connection per topic
|
||||||
|
* No connection pooling, no reference counting - keep it simple
|
||||||
|
*/
|
||||||
export class PubSubClient {
|
export class PubSubClient {
|
||||||
private httpClient: HttpClient;
|
private httpClient: HttpClient;
|
||||||
private wsConfig: Partial<WSClientConfig>;
|
private wsConfig: Partial<WSClientConfig>;
|
||||||
@ -21,20 +72,30 @@ export class PubSubClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish a message to a topic.
|
* Publish a message to a topic via HTTP
|
||||||
*/
|
*/
|
||||||
async publish(topic: string, data: string | Uint8Array): Promise<void> {
|
async publish(topic: string, data: string | Uint8Array): Promise<void> {
|
||||||
const dataBase64 =
|
let dataBase64: string;
|
||||||
typeof data === "string" ? Buffer.from(data).toString("base64") : Buffer.from(data).toString("base64");
|
if (typeof data === "string") {
|
||||||
|
dataBase64 = base64Encode(data);
|
||||||
|
} else {
|
||||||
|
dataBase64 = base64EncodeBytes(data);
|
||||||
|
}
|
||||||
|
|
||||||
await this.httpClient.post("/v1/pubsub/publish", {
|
await this.httpClient.post(
|
||||||
topic,
|
"/v1/pubsub/publish",
|
||||||
data_base64: dataBase64,
|
{
|
||||||
});
|
topic,
|
||||||
|
data_base64: dataBase64,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timeout: 30000,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List active topics in the current namespace.
|
* List active topics in the current namespace
|
||||||
*/
|
*/
|
||||||
async topics(): Promise<string[]> {
|
async topics(): Promise<string[]> {
|
||||||
const response = await this.httpClient.get<{ topics: string[] }>(
|
const response = await this.httpClient.get<{ topics: string[] }>(
|
||||||
@ -44,8 +105,8 @@ export class PubSubClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to a topic via WebSocket.
|
* Subscribe to a topic via WebSocket
|
||||||
* Returns a subscription object with event handlers.
|
* Creates one WebSocket connection per topic
|
||||||
*/
|
*/
|
||||||
async subscribe(
|
async subscribe(
|
||||||
topic: string,
|
topic: string,
|
||||||
@ -55,16 +116,23 @@ export class PubSubClient {
|
|||||||
onClose?: CloseHandler;
|
onClose?: CloseHandler;
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<Subscription> {
|
): Promise<Subscription> {
|
||||||
const wsUrl = new URL(this.wsConfig.wsURL || "ws://localhost:6001");
|
// Build WebSocket URL for this topic
|
||||||
|
const wsUrl = new URL(this.wsConfig.wsURL || "ws://127.0.0.1:6001");
|
||||||
wsUrl.pathname = "/v1/pubsub/ws";
|
wsUrl.pathname = "/v1/pubsub/ws";
|
||||||
wsUrl.searchParams.set("topic", topic);
|
wsUrl.searchParams.set("topic", topic);
|
||||||
|
|
||||||
|
const authToken = this.httpClient.getApiKey() ?? this.httpClient.getToken();
|
||||||
|
|
||||||
|
// Create WebSocket client
|
||||||
const wsClient = new WSClient({
|
const wsClient = new WSClient({
|
||||||
...this.wsConfig,
|
...this.wsConfig,
|
||||||
wsURL: wsUrl.toString(),
|
wsURL: wsUrl.toString(),
|
||||||
authToken: this.httpClient.getToken(),
|
authToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await wsClient.connect();
|
||||||
|
|
||||||
|
// Create subscription wrapper
|
||||||
const subscription = new Subscription(wsClient, topic);
|
const subscription = new Subscription(wsClient, topic);
|
||||||
|
|
||||||
if (handlers.onMessage) {
|
if (handlers.onMessage) {
|
||||||
@ -77,66 +145,144 @@ export class PubSubClient {
|
|||||||
subscription.onClose(handlers.onClose);
|
subscription.onClose(handlers.onClose);
|
||||||
}
|
}
|
||||||
|
|
||||||
await wsClient.connect();
|
|
||||||
return subscription;
|
return subscription;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription represents an active WebSocket subscription to a topic
|
||||||
|
*/
|
||||||
export class Subscription {
|
export class Subscription {
|
||||||
private wsClient: WSClient;
|
private wsClient: WSClient;
|
||||||
private topic: string;
|
private topic: string;
|
||||||
private messageHandlers: Set<MessageHandler> = new Set();
|
private messageHandlers: Set<MessageHandler> = new Set();
|
||||||
private errorHandlers: Set<ErrorHandler> = new Set();
|
private errorHandlers: Set<ErrorHandler> = new Set();
|
||||||
private closeHandlers: Set<CloseHandler> = new Set();
|
private closeHandlers: Set<CloseHandler> = new Set();
|
||||||
|
private isClosed = false;
|
||||||
|
private wsMessageHandler: ((data: string) => void) | null = null;
|
||||||
|
private wsErrorHandler: ((error: Error) => void) | null = null;
|
||||||
|
private wsCloseHandler: (() => void) | null = null;
|
||||||
|
|
||||||
constructor(wsClient: WSClient, topic: string) {
|
constructor(wsClient: WSClient, topic: string) {
|
||||||
this.wsClient = wsClient;
|
this.wsClient = wsClient;
|
||||||
this.topic = topic;
|
this.topic = topic;
|
||||||
|
|
||||||
this.wsClient.onMessage((data) => {
|
// Register message handler
|
||||||
|
this.wsMessageHandler = (data) => {
|
||||||
try {
|
try {
|
||||||
|
// Parse gateway JSON envelope: {data: base64String, timestamp, topic}
|
||||||
|
const envelope: RawEnvelope = JSON.parse(data);
|
||||||
|
|
||||||
|
// Validate envelope structure
|
||||||
|
if (!envelope || typeof envelope !== "object") {
|
||||||
|
throw new Error("Invalid envelope: not an object");
|
||||||
|
}
|
||||||
|
if (!envelope.data || typeof envelope.data !== "string") {
|
||||||
|
throw new Error("Invalid envelope: missing or invalid data field");
|
||||||
|
}
|
||||||
|
if (!envelope.topic || typeof envelope.topic !== "string") {
|
||||||
|
throw new Error("Invalid envelope: missing or invalid topic field");
|
||||||
|
}
|
||||||
|
if (typeof envelope.timestamp !== "number") {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid envelope: missing or invalid timestamp field"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode base64 data
|
||||||
|
const messageData = base64Decode(envelope.data);
|
||||||
|
|
||||||
const message: Message = {
|
const message: Message = {
|
||||||
topic: this.topic,
|
topic: envelope.topic,
|
||||||
data: data,
|
data: messageData,
|
||||||
timestamp: Date.now(),
|
timestamp: envelope.timestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log("[Subscription] Received message on topic:", this.topic);
|
||||||
this.messageHandlers.forEach((handler) => handler(message));
|
this.messageHandlers.forEach((handler) => handler(message));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("[Subscription] Error processing message:", error);
|
||||||
this.errorHandlers.forEach((handler) =>
|
this.errorHandlers.forEach((handler) =>
|
||||||
handler(error instanceof Error ? error : new Error(String(error)))
|
handler(error instanceof Error ? error : new Error(String(error)))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
this.wsClient.onError((error) => {
|
this.wsClient.onMessage(this.wsMessageHandler);
|
||||||
|
|
||||||
|
// Register error handler
|
||||||
|
this.wsErrorHandler = (error) => {
|
||||||
this.errorHandlers.forEach((handler) => handler(error));
|
this.errorHandlers.forEach((handler) => handler(error));
|
||||||
});
|
};
|
||||||
|
this.wsClient.onError(this.wsErrorHandler);
|
||||||
|
|
||||||
this.wsClient.onClose(() => {
|
// Register close handler
|
||||||
|
this.wsCloseHandler = () => {
|
||||||
this.closeHandlers.forEach((handler) => handler());
|
this.closeHandlers.forEach((handler) => handler());
|
||||||
});
|
};
|
||||||
|
this.wsClient.onClose(this.wsCloseHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMessage(handler: MessageHandler) {
|
/**
|
||||||
|
* Register message handler
|
||||||
|
*/
|
||||||
|
onMessage(handler: MessageHandler): () => void {
|
||||||
this.messageHandlers.add(handler);
|
this.messageHandlers.add(handler);
|
||||||
return () => this.messageHandlers.delete(handler);
|
return () => this.messageHandlers.delete(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
onError(handler: ErrorHandler) {
|
/**
|
||||||
|
* Register error handler
|
||||||
|
*/
|
||||||
|
onError(handler: ErrorHandler): () => void {
|
||||||
this.errorHandlers.add(handler);
|
this.errorHandlers.add(handler);
|
||||||
return () => this.errorHandlers.delete(handler);
|
return () => this.errorHandlers.delete(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
onClose(handler: CloseHandler) {
|
/**
|
||||||
|
* Register close handler
|
||||||
|
*/
|
||||||
|
onClose(handler: CloseHandler): () => void {
|
||||||
this.closeHandlers.add(handler);
|
this.closeHandlers.add(handler);
|
||||||
return () => this.closeHandlers.delete(handler);
|
return () => this.closeHandlers.delete(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
/**
|
||||||
|
* Close subscription and underlying WebSocket
|
||||||
|
*/
|
||||||
|
close(): void {
|
||||||
|
if (this.isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.isClosed = true;
|
||||||
|
|
||||||
|
// Remove handlers from WSClient
|
||||||
|
if (this.wsMessageHandler) {
|
||||||
|
this.wsClient.offMessage(this.wsMessageHandler);
|
||||||
|
this.wsMessageHandler = null;
|
||||||
|
}
|
||||||
|
if (this.wsErrorHandler) {
|
||||||
|
this.wsClient.offError(this.wsErrorHandler);
|
||||||
|
this.wsErrorHandler = null;
|
||||||
|
}
|
||||||
|
if (this.wsCloseHandler) {
|
||||||
|
this.wsClient.offClose(this.wsCloseHandler);
|
||||||
|
this.wsCloseHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all local handlers
|
||||||
|
this.messageHandlers.clear();
|
||||||
|
this.errorHandlers.clear();
|
||||||
|
this.closeHandlers.clear();
|
||||||
|
|
||||||
|
// Close WebSocket connection
|
||||||
this.wsClient.close();
|
this.wsClient.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if subscription is active
|
||||||
|
*/
|
||||||
isConnected(): boolean {
|
isConnected(): boolean {
|
||||||
return this.wsClient.isConnected();
|
return !this.isClosed && this.wsClient.isConnected();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
270
src/storage/client.ts
Normal file
270
src/storage/client.ts
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
import { HttpClient } from "../core/http";
|
||||||
|
|
||||||
|
export interface StorageUploadResponse {
|
||||||
|
cid: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoragePinRequest {
|
||||||
|
cid: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoragePinResponse {
|
||||||
|
cid: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorageStatus {
|
||||||
|
cid: string;
|
||||||
|
name: string;
|
||||||
|
status: string; // "pinned", "pinning", "queued", "unpinned", "error"
|
||||||
|
replication_min: number;
|
||||||
|
replication_max: number;
|
||||||
|
replication_factor: number;
|
||||||
|
peers: string[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StorageClient {
|
||||||
|
private httpClient: HttpClient;
|
||||||
|
|
||||||
|
constructor(httpClient: HttpClient) {
|
||||||
|
this.httpClient = httpClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload content to IPFS and optionally pin it.
|
||||||
|
* Supports both File objects (browser) and Buffer/ReadableStream (Node.js).
|
||||||
|
*
|
||||||
|
* @param file - File to upload (File, Blob, or Buffer)
|
||||||
|
* @param name - Optional filename
|
||||||
|
* @param options - Optional upload options
|
||||||
|
* @param options.pin - Whether to pin the content (default: true). Pinning happens asynchronously on the backend.
|
||||||
|
* @returns Upload result with CID
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Browser
|
||||||
|
* const fileInput = document.querySelector('input[type="file"]');
|
||||||
|
* const file = fileInput.files[0];
|
||||||
|
* const result = await client.storage.upload(file, file.name);
|
||||||
|
* console.log(result.cid);
|
||||||
|
*
|
||||||
|
* // Node.js
|
||||||
|
* const fs = require('fs');
|
||||||
|
* const fileBuffer = fs.readFileSync('image.jpg');
|
||||||
|
* const result = await client.storage.upload(fileBuffer, 'image.jpg', { pin: true });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
async upload(
|
||||||
|
file: File | Blob | ArrayBuffer | Uint8Array | ReadableStream<Uint8Array>,
|
||||||
|
name?: string,
|
||||||
|
options?: {
|
||||||
|
pin?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<StorageUploadResponse> {
|
||||||
|
// Create FormData for multipart upload
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
// Handle different input types
|
||||||
|
if (file instanceof File) {
|
||||||
|
formData.append("file", file);
|
||||||
|
} else if (file instanceof Blob) {
|
||||||
|
formData.append("file", file, name);
|
||||||
|
} else if (file instanceof ArrayBuffer) {
|
||||||
|
const blob = new Blob([file]);
|
||||||
|
formData.append("file", blob, name);
|
||||||
|
} else if (file instanceof Uint8Array) {
|
||||||
|
// Convert Uint8Array to ArrayBuffer for Blob constructor
|
||||||
|
const buffer = file.buffer.slice(
|
||||||
|
file.byteOffset,
|
||||||
|
file.byteOffset + file.byteLength
|
||||||
|
) as ArrayBuffer;
|
||||||
|
const blob = new Blob([buffer], { type: "application/octet-stream" });
|
||||||
|
formData.append("file", blob, name);
|
||||||
|
} else if (file instanceof ReadableStream) {
|
||||||
|
// For ReadableStream, we need to read it into a blob first
|
||||||
|
// This is a limitation - in practice, pass File/Blob/Buffer
|
||||||
|
const chunks: ArrayBuffer[] = [];
|
||||||
|
const reader = file.getReader();
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
const buffer = value.buffer.slice(
|
||||||
|
value.byteOffset,
|
||||||
|
value.byteOffset + value.byteLength
|
||||||
|
) as ArrayBuffer;
|
||||||
|
chunks.push(buffer);
|
||||||
|
}
|
||||||
|
const blob = new Blob(chunks);
|
||||||
|
formData.append("file", blob, name);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Unsupported file type. Use File, Blob, ArrayBuffer, Uint8Array, or ReadableStream."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add pin flag (default: true)
|
||||||
|
const shouldPin = options?.pin !== false; // Default to true
|
||||||
|
formData.append("pin", shouldPin ? "true" : "false");
|
||||||
|
|
||||||
|
return this.httpClient.uploadFile<StorageUploadResponse>(
|
||||||
|
"/v1/storage/upload",
|
||||||
|
formData,
|
||||||
|
{ timeout: 300000 } // 5 minute timeout for large files
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin an existing CID
|
||||||
|
*
|
||||||
|
* @param cid - Content ID to pin
|
||||||
|
* @param name - Optional name for the pin
|
||||||
|
* @returns Pin result
|
||||||
|
*/
|
||||||
|
async pin(cid: string, name?: string): Promise<StoragePinResponse> {
|
||||||
|
return this.httpClient.post<StoragePinResponse>("/v1/storage/pin", {
|
||||||
|
cid,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the pin status for a CID
|
||||||
|
*
|
||||||
|
* @param cid - Content ID to check
|
||||||
|
* @returns Pin status information
|
||||||
|
*/
|
||||||
|
async status(cid: string): Promise<StorageStatus> {
|
||||||
|
return this.httpClient.get<StorageStatus>(`/v1/storage/status/${cid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve content from IPFS by CID
|
||||||
|
*
|
||||||
|
* @param cid - Content ID to retrieve
|
||||||
|
* @returns ReadableStream of the content
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const stream = await client.storage.get(cid);
|
||||||
|
* const reader = stream.getReader();
|
||||||
|
* while (true) {
|
||||||
|
* const { done, value } = await reader.read();
|
||||||
|
* if (done) break;
|
||||||
|
* // Process chunk
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
async get(cid: string): Promise<ReadableStream<Uint8Array>> {
|
||||||
|
// Retry logic for content retrieval - content may not be immediately available
|
||||||
|
// after upload due to eventual consistency in IPFS Cluster
|
||||||
|
// IPFS Cluster pins can take 2-3+ seconds to complete across all nodes
|
||||||
|
const maxAttempts = 8;
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await this.httpClient.getBinary(
|
||||||
|
`/v1/storage/get/${cid}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error("Response body is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.body;
|
||||||
|
} catch (error: any) {
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
// Check if this is a 404 error (content not found)
|
||||||
|
const isNotFound =
|
||||||
|
error?.httpStatus === 404 ||
|
||||||
|
error?.message?.includes("not found") ||
|
||||||
|
error?.message?.includes("404");
|
||||||
|
|
||||||
|
// If it's not a 404 error, or this is the last attempt, give up
|
||||||
|
if (!isNotFound || attempt === maxAttempts) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait before retrying (exponential backoff: 400ms, 800ms, 1200ms, etc.)
|
||||||
|
// This gives up to ~12 seconds total wait time, covering typical pin completion
|
||||||
|
const backoffMs = attempt * 2500;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This should never be reached, but TypeScript needs it
|
||||||
|
throw lastError || new Error("Failed to retrieve content");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve content from IPFS by CID and return the full Response object
|
||||||
|
* Useful when you need access to response headers (e.g., content-length)
|
||||||
|
*
|
||||||
|
* @param cid - Content ID to retrieve
|
||||||
|
* @returns Response object with body stream and headers
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const response = await client.storage.getBinary(cid);
|
||||||
|
* const contentLength = response.headers.get('content-length');
|
||||||
|
* const reader = response.body.getReader();
|
||||||
|
* // ... read stream
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
async getBinary(cid: string): Promise<Response> {
|
||||||
|
// Retry logic for content retrieval - content may not be immediately available
|
||||||
|
// after upload due to eventual consistency in IPFS Cluster
|
||||||
|
// IPFS Cluster pins can take 2-3+ seconds to complete across all nodes
|
||||||
|
const maxAttempts = 8;
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await this.httpClient.getBinary(
|
||||||
|
`/v1/storage/get/${cid}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
throw new Error("Response is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error: any) {
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
// Check if this is a 404 error (content not found)
|
||||||
|
const isNotFound =
|
||||||
|
error?.httpStatus === 404 ||
|
||||||
|
error?.message?.includes("not found") ||
|
||||||
|
error?.message?.includes("404");
|
||||||
|
|
||||||
|
// If it's not a 404 error, or this is the last attempt, give up
|
||||||
|
if (!isNotFound || attempt === maxAttempts) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait before retrying (exponential backoff: 400ms, 800ms, 1200ms, etc.)
|
||||||
|
// This gives up to ~12 seconds total wait time, covering typical pin completion
|
||||||
|
const backoffMs = attempt * 2500;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This should never be reached, but TypeScript needs it
|
||||||
|
throw lastError || new Error("Failed to retrieve content");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpin a CID
|
||||||
|
*
|
||||||
|
* @param cid - Content ID to unpin
|
||||||
|
*/
|
||||||
|
async unpin(cid: string): Promise<void> {
|
||||||
|
await this.httpClient.delete(`/v1/storage/unpin/${cid}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
166
tests/e2e/cache.test.ts
Normal file
166
tests/e2e/cache.test.ts
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { createTestClient, skipIfNoGateway } from "./setup";
|
||||||
|
|
||||||
|
describe("Cache", () => {
|
||||||
|
if (skipIfNoGateway()) {
|
||||||
|
console.log("Skipping cache tests - gateway not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const testDMap = "test-cache";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clean up test keys before each test
|
||||||
|
const client = await createTestClient();
|
||||||
|
try {
|
||||||
|
const keys = await client.cache.scan(testDMap);
|
||||||
|
for (const key of keys.keys) {
|
||||||
|
await client.cache.delete(testDMap, key);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore errors during cleanup
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should check cache health", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
const health = await client.cache.health();
|
||||||
|
expect(health.status).toBe("ok");
|
||||||
|
expect(health.service).toBe("olric");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should put and get a value", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
const testKey = "test-key-1";
|
||||||
|
const testValue = "test-value-1";
|
||||||
|
|
||||||
|
// Put value
|
||||||
|
const putResult = await client.cache.put(testDMap, testKey, testValue);
|
||||||
|
expect(putResult.status).toBe("ok");
|
||||||
|
expect(putResult.key).toBe(testKey);
|
||||||
|
expect(putResult.dmap).toBe(testDMap);
|
||||||
|
|
||||||
|
// Get value
|
||||||
|
const getResult = await client.cache.get(testDMap, testKey);
|
||||||
|
expect(getResult).not.toBeNull();
|
||||||
|
expect(getResult!.key).toBe(testKey);
|
||||||
|
expect(getResult!.value).toBe(testValue);
|
||||||
|
expect(getResult!.dmap).toBe(testDMap);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should put and get complex objects", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
const testKey = "test-key-2";
|
||||||
|
const testValue = {
|
||||||
|
name: "John",
|
||||||
|
age: 30,
|
||||||
|
tags: ["developer", "golang"],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Put object
|
||||||
|
await client.cache.put(testDMap, testKey, testValue);
|
||||||
|
|
||||||
|
// Get object
|
||||||
|
const getResult = await client.cache.get(testDMap, testKey);
|
||||||
|
expect(getResult).not.toBeNull();
|
||||||
|
expect(getResult!.value).toBeDefined();
|
||||||
|
expect(getResult!.value.name).toBe(testValue.name);
|
||||||
|
expect(getResult!.value.age).toBe(testValue.age);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should put value with TTL", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
const testKey = "test-key-ttl";
|
||||||
|
const testValue = "ttl-value";
|
||||||
|
|
||||||
|
// Put with TTL
|
||||||
|
const putResult = await client.cache.put(
|
||||||
|
testDMap,
|
||||||
|
testKey,
|
||||||
|
testValue,
|
||||||
|
"5m"
|
||||||
|
);
|
||||||
|
expect(putResult.status).toBe("ok");
|
||||||
|
|
||||||
|
// Verify value exists
|
||||||
|
const getResult = await client.cache.get(testDMap, testKey);
|
||||||
|
expect(getResult).not.toBeNull();
|
||||||
|
expect(getResult!.value).toBe(testValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should delete a value", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
const testKey = "test-key-delete";
|
||||||
|
const testValue = "delete-me";
|
||||||
|
|
||||||
|
// Put value
|
||||||
|
await client.cache.put(testDMap, testKey, testValue);
|
||||||
|
|
||||||
|
// Verify it exists
|
||||||
|
const before = await client.cache.get(testDMap, testKey);
|
||||||
|
expect(before).not.toBeNull();
|
||||||
|
expect(before!.value).toBe(testValue);
|
||||||
|
|
||||||
|
// Delete value
|
||||||
|
const deleteResult = await client.cache.delete(testDMap, testKey);
|
||||||
|
expect(deleteResult.status).toBe("ok");
|
||||||
|
expect(deleteResult.key).toBe(testKey);
|
||||||
|
|
||||||
|
// Verify it's deleted (should return null, not throw)
|
||||||
|
const after = await client.cache.get(testDMap, testKey);
|
||||||
|
expect(after).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should scan keys", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
|
||||||
|
// Put multiple keys
|
||||||
|
await client.cache.put(testDMap, "key-1", "value-1");
|
||||||
|
await client.cache.put(testDMap, "key-2", "value-2");
|
||||||
|
await client.cache.put(testDMap, "key-3", "value-3");
|
||||||
|
|
||||||
|
// Scan all keys
|
||||||
|
const scanResult = await client.cache.scan(testDMap);
|
||||||
|
expect(scanResult.count).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(scanResult.keys).toContain("key-1");
|
||||||
|
expect(scanResult.keys).toContain("key-2");
|
||||||
|
expect(scanResult.keys).toContain("key-3");
|
||||||
|
expect(scanResult.dmap).toBe(testDMap);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should scan keys with regex match", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
|
||||||
|
// Put keys with different patterns
|
||||||
|
await client.cache.put(testDMap, "user-1", "value-1");
|
||||||
|
await client.cache.put(testDMap, "user-2", "value-2");
|
||||||
|
await client.cache.put(testDMap, "session-1", "value-3");
|
||||||
|
|
||||||
|
// Scan with regex match
|
||||||
|
const scanResult = await client.cache.scan(testDMap, "^user-");
|
||||||
|
expect(scanResult.count).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(scanResult.keys).toContain("user-1");
|
||||||
|
expect(scanResult.keys).toContain("user-2");
|
||||||
|
expect(scanResult.keys).not.toContain("session-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle non-existent key gracefully", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
const nonExistentKey = "non-existent-key";
|
||||||
|
|
||||||
|
// Cache misses should return null, not throw an error
|
||||||
|
const result = await client.cache.get(testDMap, nonExistentKey);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty dmap name", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.cache.get("", "test-key");
|
||||||
|
expect.fail("Expected get to fail with empty dmap");
|
||||||
|
} catch (err: any) {
|
||||||
|
expect(err.message).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -18,8 +18,8 @@ describe("Network", () => {
|
|||||||
const client = await createTestClient();
|
const client = await createTestClient();
|
||||||
const status = await client.network.status();
|
const status = await client.network.status();
|
||||||
expect(status).toBeDefined();
|
expect(status).toBeDefined();
|
||||||
expect(typeof status.healthy).toBe("boolean");
|
expect(typeof status.connected).toBe("boolean");
|
||||||
expect(typeof status.peers).toBe("number");
|
expect(typeof status.peer_count).toBe("number");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should list peers", async () => {
|
it("should list peers", async () => {
|
||||||
@ -27,4 +27,34 @@ describe("Network", () => {
|
|||||||
const peers = await client.network.peers();
|
const peers = await client.network.peers();
|
||||||
expect(Array.isArray(peers)).toBe(true);
|
expect(Array.isArray(peers)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should proxy request through Anyone network", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
|
||||||
|
// Test with a simple GET request
|
||||||
|
const response = await client.network.proxyAnon({
|
||||||
|
url: "https://httpbin.org/get",
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "DeBros-SDK-Test/1.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
expect(response.status_code).toBe(200);
|
||||||
|
expect(response.body).toBeDefined();
|
||||||
|
expect(typeof response.body).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle proxy errors gracefully", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
|
||||||
|
// Test with invalid URL
|
||||||
|
await expect(
|
||||||
|
client.network.proxyAnon({
|
||||||
|
url: "http://localhost:1/invalid",
|
||||||
|
method: "GET",
|
||||||
|
})
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
211
tests/e2e/storage.test.ts
Normal file
211
tests/e2e/storage.test.ts
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import { describe, it, expect, beforeAll } from "vitest";
|
||||||
|
import { createTestClient, skipIfNoGateway } from "./setup";
|
||||||
|
|
||||||
|
describe("Storage", () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
if (skipIfNoGateway()) {
|
||||||
|
console.log("Skipping storage tests");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should upload a file", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
const testContent = "Hello, IPFS!";
|
||||||
|
const testFile = new File([testContent], "test.txt", {
|
||||||
|
type: "text/plain",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.storage.upload(testFile);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.cid).toBeDefined();
|
||||||
|
expect(typeof result.cid).toBe("string");
|
||||||
|
expect(result.cid.length).toBeGreaterThan(0);
|
||||||
|
expect(result.name).toBe("test.txt");
|
||||||
|
expect(result.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should upload a Blob", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
const testContent = "Test blob content";
|
||||||
|
const blob = new Blob([testContent], { type: "text/plain" });
|
||||||
|
|
||||||
|
const result = await client.storage.upload(blob, "blob.txt");
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.cid).toBeDefined();
|
||||||
|
expect(typeof result.cid).toBe("string");
|
||||||
|
expect(result.name).toBe("blob.txt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should upload ArrayBuffer", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
const testContent = "Test array buffer";
|
||||||
|
const buffer = new TextEncoder().encode(testContent).buffer;
|
||||||
|
|
||||||
|
const result = await client.storage.upload(buffer, "buffer.bin");
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.cid).toBeDefined();
|
||||||
|
expect(typeof result.cid).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should upload Uint8Array", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
const testContent = "Test uint8array";
|
||||||
|
const uint8Array = new TextEncoder().encode(testContent);
|
||||||
|
|
||||||
|
const result = await client.storage.upload(uint8Array, "uint8.txt");
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.cid).toBeDefined();
|
||||||
|
expect(typeof result.cid).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pin a CID", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
// First upload a file to get a CID
|
||||||
|
const testContent = "File to pin";
|
||||||
|
const testFile = new File([testContent], "pin-test.txt", {
|
||||||
|
type: "text/plain",
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadResult = await client.storage.upload(testFile);
|
||||||
|
const cid = uploadResult.cid;
|
||||||
|
|
||||||
|
// Now pin it
|
||||||
|
const pinResult = await client.storage.pin(cid, "pinned-file");
|
||||||
|
|
||||||
|
expect(pinResult).toBeDefined();
|
||||||
|
expect(pinResult.cid).toBe(cid);
|
||||||
|
expect(pinResult.name).toBe("pinned-file");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should get pin status", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
// First upload and pin a file
|
||||||
|
const testContent = "File for status check";
|
||||||
|
const testFile = new File([testContent], "status-test.txt", {
|
||||||
|
type: "text/plain",
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadResult = await client.storage.upload(testFile);
|
||||||
|
await client.storage.pin(uploadResult.cid, "status-test");
|
||||||
|
|
||||||
|
// Wait a bit for pin to propagate
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
const status = await client.storage.status(uploadResult.cid);
|
||||||
|
|
||||||
|
expect(status).toBeDefined();
|
||||||
|
expect(status.cid).toBe(uploadResult.cid);
|
||||||
|
expect(status.name).toBe("status-test");
|
||||||
|
expect(status.status).toBeDefined();
|
||||||
|
expect(typeof status.status).toBe("string");
|
||||||
|
expect(status.replication_factor).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(Array.isArray(status.peers)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should retrieve content by CID", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
const testContent = "Content to retrieve";
|
||||||
|
const testFile = new File([testContent], "retrieve-test.txt", {
|
||||||
|
type: "text/plain",
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadResult = await client.storage.upload(testFile);
|
||||||
|
const cid = uploadResult.cid;
|
||||||
|
|
||||||
|
// Get the content back
|
||||||
|
const stream = await client.storage.get(cid);
|
||||||
|
|
||||||
|
expect(stream).toBeDefined();
|
||||||
|
expect(stream instanceof ReadableStream).toBe(true);
|
||||||
|
|
||||||
|
// Read the stream
|
||||||
|
const reader = stream.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
while (!done) {
|
||||||
|
const { value, done: streamDone } = await reader.read();
|
||||||
|
done = streamDone;
|
||||||
|
if (value) {
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine chunks
|
||||||
|
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||||
|
const combined = new Uint8Array(totalLength);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
combined.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const retrievedContent = new TextDecoder().decode(combined);
|
||||||
|
expect(retrievedContent).toBe(testContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should unpin a CID", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
// First upload and pin a file
|
||||||
|
const testContent = "File to unpin";
|
||||||
|
const testFile = new File([testContent], "unpin-test.txt", {
|
||||||
|
type: "text/plain",
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadResult = await client.storage.upload(testFile);
|
||||||
|
await client.storage.pin(uploadResult.cid, "unpin-test");
|
||||||
|
|
||||||
|
// Wait a bit
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
// Unpin it
|
||||||
|
await expect(client.storage.unpin(uploadResult.cid)).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle upload errors gracefully", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
// Try to upload invalid data
|
||||||
|
const invalidFile = null as any;
|
||||||
|
|
||||||
|
await expect(client.storage.upload(invalidFile)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle status errors for non-existent CID", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
const fakeCID = "QmInvalidCID123456789";
|
||||||
|
|
||||||
|
await expect(client.storage.status(fakeCID)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should upload large content", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
// Create a larger file (100KB)
|
||||||
|
const largeContent = "x".repeat(100 * 1024);
|
||||||
|
const largeFile = new File([largeContent], "large.txt", {
|
||||||
|
type: "text/plain",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.storage.upload(largeFile);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.cid).toBeDefined();
|
||||||
|
expect(result.size).toBeGreaterThanOrEqual(100 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should upload binary content", async () => {
|
||||||
|
const client = await createTestClient();
|
||||||
|
// Create binary data
|
||||||
|
const binaryData = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); // PNG header
|
||||||
|
const blob = new Blob([binaryData], { type: "image/png" });
|
||||||
|
|
||||||
|
const result = await client.storage.upload(blob, "image.png");
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.cid).toBeDefined();
|
||||||
|
expect(result.name).toBe("image.png");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,4 +1,7 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user