Add ServiceHost, ServiceClient, and RondevuService for high-level service management

- Add RondevuService: High-level API for username claiming and service publishing with Ed25519 signatures
- Add ServiceHost: Manages offer pool for hosting services with auto-replacement
- Add ServiceClient: Connects to hosted services with automatic reconnection
- Add NoOpSignaler: Placeholder signaler for connection setup
- Integrate Ed25519 signature functionality from @noble/ed25519
- Add ESLint and Prettier configuration with 4-space indentation
- Add demo with local signaling test
- Version bump to 0.10.0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-07 19:37:43 +01:00
parent 945d5a8792
commit 54355323d9
21 changed files with 5066 additions and 307 deletions

View File

@@ -2,55 +2,83 @@
* Rondevu API Client - Single class for all API endpoints
*/
import * as ed25519 from '@noble/ed25519'
// Set SHA-512 hash function for ed25519 (required in @noble/ed25519 v3+)
ed25519.hashes.sha512Async = async (message: Uint8Array) => {
return new Uint8Array(await crypto.subtle.digest('SHA-512', message as BufferSource))
}
export interface Credentials {
peerId: string;
secret: string;
peerId: string
secret: string
}
export interface Keypair {
publicKey: string
privateKey: string
}
export interface OfferRequest {
sdp: string;
topics?: string[];
ttl?: number;
secret?: string;
sdp: string
topics?: string[]
ttl?: number
secret?: string
}
export interface Offer {
id: string;
peerId: string;
sdp: string;
topics: string[];
ttl: number;
createdAt: number;
expiresAt: number;
answererPeerId?: string;
id: string
peerId: string
sdp: string
topics: string[]
ttl: number
createdAt: number
expiresAt: number
answererPeerId?: string
}
export interface ServiceRequest {
username: string;
serviceFqn: string;
sdp: string;
ttl?: number;
isPublic?: boolean;
metadata?: Record<string, any>;
signature: string;
message: string;
username: string
serviceFqn: string
sdp: string
ttl?: number
isPublic?: boolean
metadata?: Record<string, any>
signature: string
message: string
}
export interface Service {
serviceId: string;
uuid: string;
offerId: string;
username: string;
serviceFqn: string;
isPublic: boolean;
metadata?: Record<string, any>;
createdAt: number;
expiresAt: number;
serviceId: string
uuid: string
offerId: string
username: string
serviceFqn: string
isPublic: boolean
metadata?: Record<string, any>
createdAt: number
expiresAt: number
}
export interface IceCandidate {
candidate: RTCIceCandidateInit;
createdAt: number;
candidate: RTCIceCandidateInit
createdAt: number
}
/**
* Helper: Convert Uint8Array to base64 string
*/
function bytesToBase64(bytes: Uint8Array): string {
const binString = Array.from(bytes, byte => String.fromCodePoint(byte)).join('')
return btoa(binString)
}
/**
* Helper: Convert base64 string to Uint8Array
*/
function base64ToBytes(base64: string): Uint8Array {
const binString = atob(base64)
return Uint8Array.from(binString, char => char.codePointAt(0)!)
}
/**
@@ -67,11 +95,56 @@ export class RondevuAPI {
*/
private getAuthHeader(): Record<string, string> {
if (!this.credentials) {
return {};
return {}
}
return {
'Authorization': `Bearer ${this.credentials.peerId}:${this.credentials.secret}`
};
Authorization: `Bearer ${this.credentials.peerId}:${this.credentials.secret}`,
}
}
// ============================================
// Ed25519 Cryptography Helpers
// ============================================
/**
* Generate an Ed25519 keypair for username claiming and service publishing
*/
static async generateKeypair(): Promise<Keypair> {
const privateKey = ed25519.utils.randomSecretKey()
const publicKey = await ed25519.getPublicKeyAsync(privateKey)
return {
publicKey: bytesToBase64(publicKey),
privateKey: bytesToBase64(privateKey),
}
}
/**
* Sign a message with an Ed25519 private key
*/
static async signMessage(message: string, privateKeyBase64: string): Promise<string> {
const privateKey = base64ToBytes(privateKeyBase64)
const encoder = new TextEncoder()
const messageBytes = encoder.encode(message)
const signature = await ed25519.signAsync(messageBytes, privateKey)
return bytesToBase64(signature)
}
/**
* Verify a signature
*/
static async verifySignature(
message: string,
signatureBase64: string,
publicKeyBase64: string
): Promise<boolean> {
const publicKey = base64ToBytes(publicKeyBase64)
const signature = base64ToBytes(signatureBase64)
const encoder = new TextEncoder()
const messageBytes = encoder.encode(message)
return await ed25519.verifyAsync(signature, messageBytes, publicKey)
}
// ============================================
@@ -84,15 +157,15 @@ export class RondevuAPI {
async register(): Promise<Credentials> {
const response = await fetch(`${this.baseUrl}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Registration failed: ${error.error || response.statusText}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Registration failed: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
// ============================================
@@ -107,17 +180,17 @@ export class RondevuAPI {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader()
...this.getAuthHeader(),
},
body: JSON.stringify({ offers })
});
body: JSON.stringify({ offers }),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to create offers: ${error.error || response.statusText}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to create offers: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
/**
@@ -125,15 +198,15 @@ export class RondevuAPI {
*/
async getOffer(offerId: string): Promise<Offer> {
const response = await fetch(`${this.baseUrl}/offers/${offerId}`, {
headers: this.getAuthHeader()
});
headers: this.getAuthHeader(),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to get offer: ${error.error || response.statusText}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to get offer: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
/**
@@ -144,14 +217,14 @@ export class RondevuAPI {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader()
...this.getAuthHeader(),
},
body: JSON.stringify({ sdp, secret })
});
body: JSON.stringify({ sdp, secret }),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to answer offer: ${error.error || response.statusText}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to answer offer: ${error.error || response.statusText}`)
}
}
@@ -160,19 +233,19 @@ export class RondevuAPI {
*/
async getAnswer(offerId: string): Promise<{ sdp: string } | null> {
const response = await fetch(`${this.baseUrl}/offers/${offerId}/answer`, {
headers: this.getAuthHeader()
});
headers: this.getAuthHeader(),
})
if (response.status === 404) {
return null; // No answer yet
return null // No answer yet
}
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to get answer: ${error.error || response.statusText}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to get answer: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
/**
@@ -180,15 +253,15 @@ export class RondevuAPI {
*/
async searchOffers(topic: string): Promise<Offer[]> {
const response = await fetch(`${this.baseUrl}/offers?topic=${encodeURIComponent(topic)}`, {
headers: this.getAuthHeader()
});
headers: this.getAuthHeader(),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to search offers: ${error.error || response.statusText}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to search offers: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
// ============================================
@@ -203,14 +276,14 @@ export class RondevuAPI {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader()
...this.getAuthHeader(),
},
body: JSON.stringify({ candidates })
});
body: JSON.stringify({ candidates }),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to add ICE candidates: ${error.error || response.statusText}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to add ICE candidates: ${error.error || response.statusText}`)
}
}
@@ -221,14 +294,14 @@ export class RondevuAPI {
const response = await fetch(
`${this.baseUrl}/offers/${offerId}/ice-candidates?since=${since}`,
{ headers: this.getAuthHeader() }
);
)
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to get ICE candidates: ${error.error || response.statusText}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to get ICE candidates: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
// ============================================
@@ -243,17 +316,17 @@ export class RondevuAPI {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader()
...this.getAuthHeader(),
},
body: JSON.stringify(service)
});
body: JSON.stringify(service),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to publish service: ${error.error || response.statusText}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to publish service: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
/**
@@ -261,15 +334,15 @@ export class RondevuAPI {
*/
async getService(uuid: string): Promise<Service & { offerId: string; sdp: string }> {
const response = await fetch(`${this.baseUrl}/services/${uuid}`, {
headers: this.getAuthHeader()
});
headers: this.getAuthHeader(),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to get service: ${error.error || response.statusText}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to get service: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
/**
@@ -279,14 +352,14 @@ export class RondevuAPI {
const response = await fetch(
`${this.baseUrl}/services?username=${encodeURIComponent(username)}`,
{ headers: this.getAuthHeader() }
);
)
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to search services: ${error.error || response.statusText}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to search services: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
/**
@@ -296,14 +369,14 @@ export class RondevuAPI {
const response = await fetch(
`${this.baseUrl}/services?serviceFqn=${encodeURIComponent(serviceFqn)}`,
{ headers: this.getAuthHeader() }
);
)
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to search services: ${error.error || response.statusText}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to search services: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
/**
@@ -313,14 +386,14 @@ export class RondevuAPI {
const response = await fetch(
`${this.baseUrl}/services?username=${encodeURIComponent(username)}&serviceFqn=${encodeURIComponent(serviceFqn)}`,
{ headers: this.getAuthHeader() }
);
)
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to search services: ${error.error || response.statusText}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to search services: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
// ============================================
@@ -333,14 +406,14 @@ export class RondevuAPI {
async checkUsername(username: string): Promise<{ available: boolean; owner?: string }> {
const response = await fetch(
`${this.baseUrl}/usernames/${encodeURIComponent(username)}/check`
);
)
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to check username: ${error.error || response.statusText}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to check username: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
/**
@@ -356,20 +429,20 @@ export class RondevuAPI {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader()
...this.getAuthHeader(),
},
body: JSON.stringify({
publicKey,
signature,
message
})
});
message,
}),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Failed to claim username: ${error.error || response.statusText}`);
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to claim username: ${error.error || response.statusText}`)
}
return await response.json();
return await response.json()
}
}