mirror of
https://github.com/xtr-dev/rondevu-server.git
synced 2025-12-09 18:33:24 +00:00
feat: refactor to service-based WebRTC signaling endpoints
BREAKING CHANGE: Replace offer-based endpoints with service-based signaling - Add POST /services/:uuid/answer - Add GET /services/:uuid/answer - Add POST /services/:uuid/ice-candidates - Add GET /services/:uuid/ice-candidates - Remove all /offers/* endpoints (POST /offers, GET /offers/mine, etc.) - Server auto-detects peer's offer when offerId is omitted - Update README with new service-based API documentation - Bump version to 0.4.0 This change simplifies the API by focusing on services rather than individual offers. WebRTC signaling (answer/ICE) now operates at the service level, with automatic offer detection when needed.
This commit is contained in:
96
README.md
96
README.md
@@ -240,35 +240,14 @@ Unpublish a service (requires authentication and ownership)
|
||||
}
|
||||
```
|
||||
|
||||
### Offer Management (Low-level)
|
||||
### WebRTC Signaling (Service-Based)
|
||||
|
||||
#### `POST /offers`
|
||||
Create one or more offers (requires authentication)
|
||||
#### `POST /services/:uuid/answer`
|
||||
Answer a service offer (requires authentication)
|
||||
|
||||
**Headers:**
|
||||
- `Authorization: Bearer {peerId}:{secret}`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"offers": [
|
||||
{
|
||||
"sdp": "v=0...",
|
||||
"ttl": 300000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /offers/mine`
|
||||
List all offers owned by authenticated peer
|
||||
|
||||
#### `DELETE /offers/:offerId`
|
||||
Delete a specific offer
|
||||
|
||||
#### `POST /offers/:offerId/answer`
|
||||
Answer an offer (locks it to answerer)
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
@@ -276,21 +255,76 @@ Answer an offer (locks it to answerer)
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /offers/:offerId/answer`
|
||||
Get answer for a specific offer
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"offerId": "offer-hash"
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /offers/:offerId/ice-candidates`
|
||||
Post ICE candidates for an offer
|
||||
#### `GET /services/:uuid/answer`
|
||||
Get answer for a service (offerer polls this)
|
||||
|
||||
**Headers:**
|
||||
- `Authorization: Bearer {peerId}:{secret}`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"offerId": "offer-hash",
|
||||
"answererId": "answerer-peer-id",
|
||||
"sdp": "v=0...",
|
||||
"answeredAt": 1733404800000
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Returns 404 if not yet answered
|
||||
|
||||
#### `POST /services/:uuid/ice-candidates`
|
||||
Post ICE candidates for a service (requires authentication)
|
||||
|
||||
**Headers:**
|
||||
- `Authorization: Bearer {peerId}:{secret}`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"candidates": ["candidate:1 1 UDP..."]
|
||||
"candidates": ["candidate:1 1 UDP..."],
|
||||
"offerId": "optional-offer-id"
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /offers/:offerId/ice-candidates?since=1234567890`
|
||||
Get ICE candidates from the other peer
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"count": 1,
|
||||
"offerId": "offer-hash"
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** If `offerId` is omitted, the server will auto-detect the peer's offer
|
||||
|
||||
#### `GET /services/:uuid/ice-candidates?since=1234567890&offerId=optional-offer-id`
|
||||
Get ICE candidates from the other peer (requires authentication)
|
||||
|
||||
**Headers:**
|
||||
- `Authorization: Bearer {peerId}:{secret}`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"candidate": "candidate:1 1 UDP...",
|
||||
"createdAt": 1733404800000
|
||||
}
|
||||
],
|
||||
"offerId": "offer-hash"
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Returns candidates from the opposite role (offerer gets answerer candidates and vice versa)
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@xtr-dev/rondevu-server",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@xtr-dev/rondevu-server",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.6",
|
||||
"@noble/ed25519": "^3.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xtr-dev/rondevu-server",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "DNS-like WebRTC signaling server with username claiming and service discovery",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
286
src/app.ts
286
src/app.ts
@@ -444,156 +444,17 @@ export function createApp(storage: Storage, config: Config) {
|
||||
}
|
||||
});
|
||||
|
||||
// ===== Offer Management (Core WebRTC) =====
|
||||
// ===== Service-Based WebRTC Signaling =====
|
||||
|
||||
/**
|
||||
* POST /offers
|
||||
* Create offers (direct, no service - for testing/advanced users)
|
||||
* POST /services/:uuid/answer
|
||||
* Answer a service offer
|
||||
*/
|
||||
app.post('/offers', authMiddleware, async (c) => {
|
||||
app.post('/services/:uuid/answer', authMiddleware, async (c) => {
|
||||
try {
|
||||
const uuid = c.req.param('uuid');
|
||||
const body = await c.req.json();
|
||||
const { offers } = body;
|
||||
|
||||
if (!Array.isArray(offers) || offers.length === 0) {
|
||||
return c.json({ error: 'Missing or invalid required parameter: offers (must be non-empty array)' }, 400);
|
||||
}
|
||||
|
||||
if (offers.length > config.maxOffersPerRequest) {
|
||||
return c.json({ error: `Too many offers (max ${config.maxOffersPerRequest})` }, 400);
|
||||
}
|
||||
|
||||
const peerId = getAuthenticatedPeerId(c);
|
||||
|
||||
// Validate and prepare offers
|
||||
const validated = offers.map((offer: any) => {
|
||||
const { sdp, ttl, secret } = offer;
|
||||
|
||||
if (typeof sdp !== 'string' || sdp.length === 0) {
|
||||
throw new Error('Invalid SDP in offer');
|
||||
}
|
||||
|
||||
if (sdp.length > 64 * 1024) {
|
||||
throw new Error('SDP too large (max 64KB)');
|
||||
}
|
||||
|
||||
const offerTtl = Math.min(
|
||||
Math.max(ttl || config.offerDefaultTtl, config.offerMinTtl),
|
||||
config.offerMaxTtl
|
||||
);
|
||||
|
||||
return {
|
||||
peerId,
|
||||
sdp,
|
||||
expiresAt: Date.now() + offerTtl,
|
||||
secret: secret ? String(secret).substring(0, 128) : undefined
|
||||
};
|
||||
});
|
||||
|
||||
const created = await storage.createOffers(validated);
|
||||
|
||||
return c.json({
|
||||
offers: created.map(offer => ({
|
||||
id: offer.id,
|
||||
peerId: offer.peerId,
|
||||
expiresAt: offer.expiresAt,
|
||||
createdAt: offer.createdAt,
|
||||
hasSecret: !!offer.secret
|
||||
}))
|
||||
}, 201);
|
||||
} catch (err: any) {
|
||||
console.error('Error creating offers:', err);
|
||||
return c.json({ error: err.message || 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /offers/mine
|
||||
* Get authenticated peer's offers
|
||||
*/
|
||||
app.get('/offers/mine', authMiddleware, async (c) => {
|
||||
try {
|
||||
const peerId = getAuthenticatedPeerId(c);
|
||||
const offers = await storage.getOffersByPeerId(peerId);
|
||||
|
||||
return c.json({
|
||||
offers: offers.map(offer => ({
|
||||
id: offer.id,
|
||||
sdp: offer.sdp,
|
||||
createdAt: offer.createdAt,
|
||||
expiresAt: offer.expiresAt,
|
||||
lastSeen: offer.lastSeen,
|
||||
hasSecret: !!offer.secret,
|
||||
answererPeerId: offer.answererPeerId,
|
||||
answered: !!offer.answererPeerId
|
||||
}))
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error getting offers:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /offers/:offerId
|
||||
* Get offer details (added for completeness)
|
||||
*/
|
||||
app.get('/offers/:offerId', authMiddleware, async (c) => {
|
||||
try {
|
||||
const offerId = c.req.param('offerId');
|
||||
const offer = await storage.getOfferById(offerId);
|
||||
|
||||
if (!offer) {
|
||||
return c.json({ error: 'Offer not found' }, 404);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: offer.id,
|
||||
peerId: offer.peerId,
|
||||
sdp: offer.sdp,
|
||||
createdAt: offer.createdAt,
|
||||
expiresAt: offer.expiresAt,
|
||||
answererPeerId: offer.answererPeerId,
|
||||
answered: !!offer.answererPeerId,
|
||||
answerSdp: offer.answerSdp
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error getting offer:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /offers/:offerId
|
||||
* Delete an offer
|
||||
*/
|
||||
app.delete('/offers/:offerId', authMiddleware, async (c) => {
|
||||
try {
|
||||
const offerId = c.req.param('offerId');
|
||||
const peerId = getAuthenticatedPeerId(c);
|
||||
|
||||
const deleted = await storage.deleteOffer(offerId, peerId);
|
||||
|
||||
if (!deleted) {
|
||||
return c.json({ error: 'Offer not found or not owned by this peer' }, 404);
|
||||
}
|
||||
|
||||
return c.json({ success: true }, 200);
|
||||
} catch (err) {
|
||||
console.error('Error deleting offer:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /offers/:offerId/answer
|
||||
* Answer an offer
|
||||
*/
|
||||
app.post('/offers/:offerId/answer', authMiddleware, async (c) => {
|
||||
try {
|
||||
const offerId = c.req.param('offerId');
|
||||
const body = await c.req.json();
|
||||
const { sdp, secret } = body;
|
||||
const { sdp } = body;
|
||||
|
||||
if (!sdp) {
|
||||
return c.json({ error: 'Missing required parameter: sdp' }, 400);
|
||||
@@ -607,69 +468,82 @@ export function createApp(storage: Storage, config: Config) {
|
||||
return c.json({ error: 'SDP too large (max 64KB)' }, 400);
|
||||
}
|
||||
|
||||
// Get the service by UUID
|
||||
const service = await storage.getServiceByUuid(uuid);
|
||||
if (!service) {
|
||||
return c.json({ error: 'Service not found' }, 404);
|
||||
}
|
||||
|
||||
// Get available offer from service
|
||||
const serviceOffers = await storage.getOffersForService(service.id);
|
||||
const availableOffer = serviceOffers.find(offer => !offer.answererPeerId);
|
||||
|
||||
if (!availableOffer) {
|
||||
return c.json({ error: 'No available offers' }, 503);
|
||||
}
|
||||
|
||||
const answererPeerId = getAuthenticatedPeerId(c);
|
||||
|
||||
const result = await storage.answerOffer(offerId, answererPeerId, sdp, secret);
|
||||
const result = await storage.answerOffer(availableOffer.id, answererPeerId, sdp);
|
||||
|
||||
if (!result.success) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
|
||||
return c.json({ success: true }, 200);
|
||||
return c.json({
|
||||
success: true,
|
||||
offerId: availableOffer.id
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error answering offer:', err);
|
||||
console.error('Error answering service:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /offers/:offerId/answer
|
||||
* Get answer for a specific offer (RESTful endpoint)
|
||||
* GET /services/:uuid/answer
|
||||
* Get answer for a service (offerer polls this)
|
||||
*/
|
||||
app.get('/offers/:offerId/answer', authMiddleware, async (c) => {
|
||||
app.get('/services/:uuid/answer', authMiddleware, async (c) => {
|
||||
try {
|
||||
const offerId = c.req.param('offerId');
|
||||
const uuid = c.req.param('uuid');
|
||||
const peerId = getAuthenticatedPeerId(c);
|
||||
|
||||
const offer = await storage.getOfferById(offerId);
|
||||
|
||||
if (!offer) {
|
||||
return c.json({ error: 'Offer not found' }, 404);
|
||||
// Get the service by UUID
|
||||
const service = await storage.getServiceByUuid(uuid);
|
||||
if (!service) {
|
||||
return c.json({ error: 'Service not found' }, 404);
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (offer.peerId !== peerId) {
|
||||
return c.json({ error: 'Not authorized to view this answer' }, 403);
|
||||
}
|
||||
// Get offers for this service owned by the requesting peer
|
||||
const serviceOffers = await storage.getOffersForService(service.id);
|
||||
const myOffer = serviceOffers.find(offer => offer.peerId === peerId && offer.answererPeerId);
|
||||
|
||||
// Check if answered
|
||||
if (!offer.answererPeerId || !offer.answerSdp) {
|
||||
if (!myOffer || !myOffer.answerSdp) {
|
||||
return c.json({ error: 'Offer not yet answered' }, 404);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
offerId: offer.id,
|
||||
answererId: offer.answererPeerId,
|
||||
sdp: offer.answerSdp,
|
||||
answeredAt: offer.answeredAt
|
||||
offerId: myOffer.id,
|
||||
answererId: myOffer.answererPeerId,
|
||||
sdp: myOffer.answerSdp,
|
||||
answeredAt: myOffer.answeredAt
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error getting answer:', err);
|
||||
console.error('Error getting service answer:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ===== ICE Candidate Exchange =====
|
||||
|
||||
/**
|
||||
* POST /offers/:offerId/ice-candidates
|
||||
* Add ICE candidates for an offer
|
||||
* POST /services/:uuid/ice-candidates
|
||||
* Add ICE candidates for a service
|
||||
*/
|
||||
app.post('/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
|
||||
app.post('/services/:uuid/ice-candidates', authMiddleware, async (c) => {
|
||||
try {
|
||||
const offerId = c.req.param('offerId');
|
||||
const uuid = c.req.param('uuid');
|
||||
const body = await c.req.json();
|
||||
const { candidates } = body;
|
||||
const { candidates, offerId } = body;
|
||||
|
||||
if (!Array.isArray(candidates) || candidates.length === 0) {
|
||||
return c.json({ error: 'Missing or invalid required parameter: candidates' }, 400);
|
||||
@@ -677,8 +551,27 @@ export function createApp(storage: Storage, config: Config) {
|
||||
|
||||
const peerId = getAuthenticatedPeerId(c);
|
||||
|
||||
// Get the service by UUID
|
||||
const service = await storage.getServiceByUuid(uuid);
|
||||
if (!service) {
|
||||
return c.json({ error: 'Service not found' }, 404);
|
||||
}
|
||||
|
||||
// If offerId is provided, use it; otherwise find the peer's offer
|
||||
let targetOfferId = offerId;
|
||||
if (!targetOfferId) {
|
||||
const serviceOffers = await storage.getOffersForService(service.id);
|
||||
const myOffer = serviceOffers.find(offer =>
|
||||
offer.peerId === peerId || offer.answererPeerId === peerId
|
||||
);
|
||||
if (!myOffer) {
|
||||
return c.json({ error: 'No offer found for this peer' }, 404);
|
||||
}
|
||||
targetOfferId = myOffer.id;
|
||||
}
|
||||
|
||||
// Get offer to determine role
|
||||
const offer = await storage.getOfferById(offerId);
|
||||
const offer = await storage.getOfferById(targetOfferId);
|
||||
if (!offer) {
|
||||
return c.json({ error: 'Offer not found' }, 404);
|
||||
}
|
||||
@@ -686,27 +579,47 @@ export function createApp(storage: Storage, config: Config) {
|
||||
// Determine role
|
||||
const role = offer.peerId === peerId ? 'offerer' : 'answerer';
|
||||
|
||||
const count = await storage.addIceCandidates(offerId, peerId, role, candidates);
|
||||
const count = await storage.addIceCandidates(targetOfferId, peerId, role, candidates);
|
||||
|
||||
return c.json({ count }, 200);
|
||||
return c.json({ count, offerId: targetOfferId }, 200);
|
||||
} catch (err) {
|
||||
console.error('Error adding ICE candidates:', err);
|
||||
console.error('Error adding ICE candidates to service:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /offers/:offerId/ice-candidates
|
||||
* Get ICE candidates for an offer
|
||||
* GET /services/:uuid/ice-candidates
|
||||
* Get ICE candidates for a service
|
||||
*/
|
||||
app.get('/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
|
||||
app.get('/services/:uuid/ice-candidates', authMiddleware, async (c) => {
|
||||
try {
|
||||
const offerId = c.req.param('offerId');
|
||||
const uuid = c.req.param('uuid');
|
||||
const since = c.req.query('since');
|
||||
const offerId = c.req.query('offerId');
|
||||
const peerId = getAuthenticatedPeerId(c);
|
||||
|
||||
// Get the service by UUID
|
||||
const service = await storage.getServiceByUuid(uuid);
|
||||
if (!service) {
|
||||
return c.json({ error: 'Service not found' }, 404);
|
||||
}
|
||||
|
||||
// If offerId is provided, use it; otherwise find the peer's offer
|
||||
let targetOfferId = offerId;
|
||||
if (!targetOfferId) {
|
||||
const serviceOffers = await storage.getOffersForService(service.id);
|
||||
const myOffer = serviceOffers.find(offer =>
|
||||
offer.peerId === peerId || offer.answererPeerId === peerId
|
||||
);
|
||||
if (!myOffer) {
|
||||
return c.json({ error: 'No offer found for this peer' }, 404);
|
||||
}
|
||||
targetOfferId = myOffer.id;
|
||||
}
|
||||
|
||||
// Get offer to determine role
|
||||
const offer = await storage.getOfferById(offerId);
|
||||
const offer = await storage.getOfferById(targetOfferId);
|
||||
if (!offer) {
|
||||
return c.json({ error: 'Offer not found' }, 404);
|
||||
}
|
||||
@@ -715,16 +628,17 @@ export function createApp(storage: Storage, config: Config) {
|
||||
const targetRole = offer.peerId === peerId ? 'answerer' : 'offerer';
|
||||
const sinceTimestamp = since ? parseInt(since, 10) : undefined;
|
||||
|
||||
const candidates = await storage.getIceCandidates(offerId, targetRole, sinceTimestamp);
|
||||
const candidates = await storage.getIceCandidates(targetOfferId, targetRole, sinceTimestamp);
|
||||
|
||||
return c.json({
|
||||
candidates: candidates.map(c => ({
|
||||
candidate: c.candidate,
|
||||
createdAt: c.createdAt
|
||||
}))
|
||||
})),
|
||||
offerId: targetOfferId
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
console.error('Error getting ICE candidates:', err);
|
||||
console.error('Error getting ICE candidates for service:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user