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:
2025-12-07 22:17:24 +01:00
parent 2aa1fee4d6
commit 1d70cd79e8
4 changed files with 168 additions and 220 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -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": {

View File

@@ -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);
}
});