Initial Commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Public surface for the SDP offer/answer primitives.
|
||||
*/
|
||||
|
||||
export { createOffer, handleAnswer, handleOffer } from "./offer-answer.js";
|
||||
@@ -0,0 +1,111 @@
|
||||
# Offer / Answer Primitives
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The offer/answer module exposes three pure async wrappers around the
|
||||
spec-standard WebRTC handshake calls. `createOffer` covers the outbound
|
||||
half (local offer + `setLocalDescription`), `handleOffer` covers the
|
||||
inbound half (`setRemoteDescription` + answer + `setLocalDescription`),
|
||||
and `handleAnswer` finalises an outbound handshake by applying the
|
||||
remote answer. Each helper takes an `RTCPeerConnection` plus an optional
|
||||
`AbortSignal` and never owns peer state itself — that lives in
|
||||
`WebRTCClient`.
|
||||
|
||||
Splitting these out keeps `WebRTCClient` thin and testable. The handshake
|
||||
logic is a deterministic three- or four-step pipeline that maps
|
||||
beautifully to property tests against a structural `FakeRTCPeerConnection`;
|
||||
the orchestrator can then focus on lifecycle, eventing, and connection
|
||||
caching without having to re-stub the same `pc.createOffer().then(...)`
|
||||
chain in every code path.
|
||||
|
||||
## Features
|
||||
|
||||
- Spec-faithful call ordering: `createOffer → setLocalDescription`,
|
||||
`setRemoteDescription → createAnswer → setLocalDescription`.
|
||||
- Every failure surfaces as `DriveWebRtcError(PEER_HANDSHAKE_FAILED)`
|
||||
with the underlying error preserved via `cause`.
|
||||
- `AbortSignal` is checked **before** every async step, so a pre-aborted
|
||||
signal rejects immediately and a signal aborted mid-handshake rejects
|
||||
at the next step boundary.
|
||||
- Pure functions over an injected `RTCPeerConnection` — no module-level
|
||||
state, no globals, no hidden allocations.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createOffer, handleAnswer } from "@suaveplan/drive-webrtc";
|
||||
|
||||
const pc = new RTCPeerConnection();
|
||||
const offer = await createOffer(pc);
|
||||
sendToPeer({ type: "offer", payload: offer });
|
||||
|
||||
const answer = await receiveAnswerFromPeer();
|
||||
await handleAnswer(pc, answer);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createOffer, handleOffer } from "@suaveplan/drive-webrtc";
|
||||
|
||||
const pc = new RTCPeerConnection({ iceServers });
|
||||
|
||||
// Inbound caller — respond to a remote offer with an abort budget.
|
||||
const ac = new AbortController();
|
||||
setTimeout(() => ac.abort(), 5_000);
|
||||
|
||||
try {
|
||||
const answer = await handleOffer(pc, remoteOffer, ac.signal);
|
||||
sendToPeer({ type: "answer", payload: answer });
|
||||
} catch (err) {
|
||||
if (err instanceof DriveWebRtcError && err.code === DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED) {
|
||||
// Handshake failed — log + tear down the peer connection.
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Outbound caller — create an offer and survive a transient
|
||||
// setLocalDescription error.
|
||||
try {
|
||||
const offer = await createOffer(pc);
|
||||
sendToPeer({ type: "offer", payload: offer });
|
||||
} catch (err) {
|
||||
pc.close();
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createOffer(pc, signal?): Promise<RTCSessionDescriptionInit>`
|
||||
|
||||
Creates an SDP offer on `pc`, applies it as local description, returns
|
||||
the offer. Throws `DriveWebRtcError(PEER_HANDSHAKE_FAILED)` on abort or
|
||||
any step failure (original error preserved via `cause`).
|
||||
|
||||
### `handleOffer(pc, offer, signal?): Promise<RTCSessionDescriptionInit>`
|
||||
|
||||
Applies `offer` as remote description, generates an answer, applies it
|
||||
as local description, returns the answer. Same error semantics as
|
||||
`createOffer`.
|
||||
|
||||
### `handleAnswer(pc, answer, signal?): Promise<void>`
|
||||
|
||||
Applies `answer` as remote description on a `pc` that has already sent
|
||||
an offer. Same error semantics as `createOffer`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Abort checks happen between every awaited step so an abort that lands
|
||||
during `createOffer` does not silently proceed into
|
||||
`setLocalDescription`.
|
||||
- The wrapper never mutates `pc` outside of the documented calls and never
|
||||
registers event listeners — handler wiring is the orchestrator's
|
||||
responsibility (see `client.ts`).
|
||||
- The structural typing of `RTCPeerConnection` means these helpers work
|
||||
unchanged against the project's `FakeRTCPeerConnection` test double;
|
||||
the no-mocks rule is satisfied by injection at the call site, not by
|
||||
monkey-patching globals.
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Tests for the offer/answer primitives. Use a minimal structural
|
||||
* FakeRTCPeerConnection — no `vi.mock`, no globals touched.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DRIVE_WEBRTC_CODES,
|
||||
DriveWebRtcError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { createOffer, handleAnswer, handleOffer } from "./offer-answer.js";
|
||||
|
||||
class FakePc {
|
||||
public localDescription: RTCSessionDescriptionInit | null = null;
|
||||
public remoteDescription: RTCSessionDescriptionInit | null = null;
|
||||
public createOfferImpl: () => Promise<RTCSessionDescriptionInit> =
|
||||
async () => ({ type: "offer", sdp: "o" });
|
||||
public createAnswerImpl: () => Promise<RTCSessionDescriptionInit> =
|
||||
async () => ({ type: "answer", sdp: "a" });
|
||||
public setLocalImpl: (d: RTCSessionDescriptionInit) => Promise<void> =
|
||||
async () => {};
|
||||
public setRemoteImpl: (d: RTCSessionDescriptionInit) => Promise<void> =
|
||||
async () => {};
|
||||
|
||||
public createOffer(): Promise<RTCSessionDescriptionInit> {
|
||||
return this.createOfferImpl();
|
||||
}
|
||||
public createAnswer(): Promise<RTCSessionDescriptionInit> {
|
||||
return this.createAnswerImpl();
|
||||
}
|
||||
public async setLocalDescription(
|
||||
d: RTCSessionDescriptionInit
|
||||
): Promise<void> {
|
||||
await this.setLocalImpl(d);
|
||||
this.localDescription = d;
|
||||
}
|
||||
public async setRemoteDescription(
|
||||
d: RTCSessionDescriptionInit
|
||||
): Promise<void> {
|
||||
await this.setRemoteImpl(d);
|
||||
this.remoteDescription = d;
|
||||
}
|
||||
}
|
||||
|
||||
function pc(): FakePc & RTCPeerConnection {
|
||||
return new FakePc() as unknown as FakePc & RTCPeerConnection;
|
||||
}
|
||||
|
||||
describe("createOffer", () => {
|
||||
it("creates offer and applies it as local description", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = pc();
|
||||
const offer = await createOffer(p);
|
||||
expect(offer).toEqual({ type: "offer", sdp: "o" });
|
||||
expect(p.localDescription).toEqual(offer);
|
||||
});
|
||||
|
||||
it("pre-aborted rejects with PEER_HANDSHAKE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
await createOffer(pc(), ac.signal);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as DriveWebRtcError).code).toBe(
|
||||
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts mid-handshake before setLocalDescription", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ac = new AbortController();
|
||||
const p = pc();
|
||||
(p as unknown as FakePc).createOfferImpl = async () => {
|
||||
ac.abort();
|
||||
return { type: "offer", sdp: "o" };
|
||||
};
|
||||
try {
|
||||
await createOffer(p, ac.signal);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as DriveWebRtcError).code).toBe(
|
||||
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
|
||||
);
|
||||
}
|
||||
expect((p as unknown as FakePc).localDescription).toBeNull();
|
||||
});
|
||||
|
||||
it("wraps createOffer failure", { timeout: 5000 }, async () => {
|
||||
const p = pc();
|
||||
(p as unknown as FakePc).createOfferImpl = async () => {
|
||||
throw new Error("nope");
|
||||
};
|
||||
try {
|
||||
await createOffer(p);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DriveWebRtcError);
|
||||
expect((err as DriveWebRtcError).code).toBe(
|
||||
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
|
||||
);
|
||||
expect((err as DriveWebRtcError).cause).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps setLocalDescription failure", { timeout: 5000 }, async () => {
|
||||
const p = pc();
|
||||
(p as unknown as FakePc).setLocalImpl = async () => {
|
||||
throw new Error("sld bad");
|
||||
};
|
||||
try {
|
||||
await createOffer(p);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as DriveWebRtcError).code).toBe(
|
||||
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleOffer", () => {
|
||||
const remote: RTCSessionDescriptionInit = { type: "offer", sdp: "r" };
|
||||
|
||||
it("applies remote then returns answer", { timeout: 5000 }, async () => {
|
||||
const p = pc();
|
||||
const answer = await handleOffer(p, remote);
|
||||
expect(answer).toEqual({ type: "answer", sdp: "a" });
|
||||
expect((p as unknown as FakePc).remoteDescription).toEqual(remote);
|
||||
expect((p as unknown as FakePc).localDescription).toEqual(answer);
|
||||
});
|
||||
|
||||
it("pre-aborted rejects", { timeout: 5000 }, async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
await handleOffer(pc(), remote, ac.signal);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as DriveWebRtcError).code).toBe(
|
||||
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts mid-handshake after setRemoteDescription", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ac = new AbortController();
|
||||
const p = pc();
|
||||
(p as unknown as FakePc).setRemoteImpl = async () => {
|
||||
ac.abort();
|
||||
};
|
||||
try {
|
||||
await handleOffer(p, remote, ac.signal);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as DriveWebRtcError).code).toBe(
|
||||
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts before setLocalDescription(answer)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ac = new AbortController();
|
||||
const p = pc();
|
||||
(p as unknown as FakePc).createAnswerImpl = async () => {
|
||||
ac.abort();
|
||||
return { type: "answer", sdp: "a" };
|
||||
};
|
||||
try {
|
||||
await handleOffer(p, remote, ac.signal);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as DriveWebRtcError).code).toBe(
|
||||
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps setRemoteDescription failure", { timeout: 5000 }, async () => {
|
||||
const p = pc();
|
||||
(p as unknown as FakePc).setRemoteImpl = async () => {
|
||||
throw new Error("bad remote");
|
||||
};
|
||||
try {
|
||||
await handleOffer(p, remote);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as DriveWebRtcError).code).toBe(
|
||||
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps createAnswer failure", { timeout: 5000 }, async () => {
|
||||
const p = pc();
|
||||
(p as unknown as FakePc).createAnswerImpl = async () => {
|
||||
throw new Error("answer bad");
|
||||
};
|
||||
try {
|
||||
await handleOffer(p, remote);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as DriveWebRtcError).code).toBe(
|
||||
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps setLocalDescription(answer) failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = pc();
|
||||
(p as unknown as FakePc).setLocalImpl = async () => {
|
||||
throw new Error("local answer bad");
|
||||
};
|
||||
try {
|
||||
await handleOffer(p, remote);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as DriveWebRtcError).code).toBe(
|
||||
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAnswer", () => {
|
||||
const remote: RTCSessionDescriptionInit = { type: "answer", sdp: "r" };
|
||||
|
||||
it("applies the remote answer", { timeout: 5000 }, async () => {
|
||||
const p = pc();
|
||||
await handleAnswer(p, remote);
|
||||
expect((p as unknown as FakePc).remoteDescription).toEqual(remote);
|
||||
});
|
||||
|
||||
it("pre-aborted rejects", { timeout: 5000 }, async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
await handleAnswer(pc(), remote, ac.signal);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as DriveWebRtcError).code).toBe(
|
||||
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps setRemoteDescription failure", { timeout: 5000 }, async () => {
|
||||
const p = pc();
|
||||
(p as unknown as FakePc).setRemoteImpl = async () => {
|
||||
throw new Error("no");
|
||||
};
|
||||
try {
|
||||
await handleAnswer(p, remote);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as DriveWebRtcError).code).toBe(
|
||||
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* SDP offer / answer primitives.
|
||||
*
|
||||
* Pure async wrappers over the spec-standard
|
||||
* `pc.createOffer() → pc.setLocalDescription()` pattern (and inverse).
|
||||
* Every failure mode is translated into `DriveWebRtcError(PEER_HANDSHAKE_FAILED)`
|
||||
* with the original DOMException attached as `cause` so callers never see
|
||||
* raw browser errors leak past the package boundary.
|
||||
*/
|
||||
|
||||
import {
|
||||
DRIVE_WEBRTC_CODES,
|
||||
DriveWebRtcError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
|
||||
function throwIfAborted(signal: AbortSignal | undefined, where: string): void {
|
||||
if (signal?.aborted === true) {
|
||||
throw new DriveWebRtcError(
|
||||
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED,
|
||||
`${where} aborted`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function wrap(cause: unknown, message: string): DriveWebRtcError {
|
||||
return new DriveWebRtcError(
|
||||
DRIVE_WEBRTC_CODES.PEER_HANDSHAKE_FAILED,
|
||||
message,
|
||||
{ cause: cause as Error }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an SDP offer on `pc`, apply it as local description, and return it.
|
||||
*/
|
||||
export async function createOffer(
|
||||
pc: RTCPeerConnection,
|
||||
signal?: AbortSignal
|
||||
): Promise<RTCSessionDescriptionInit> {
|
||||
throwIfAborted(signal, "createOffer");
|
||||
let offer: RTCSessionDescriptionInit;
|
||||
try {
|
||||
offer = await pc.createOffer();
|
||||
} catch (cause) {
|
||||
throw wrap(cause, "RTCPeerConnection.createOffer failed");
|
||||
}
|
||||
throwIfAborted(signal, "createOffer");
|
||||
try {
|
||||
await pc.setLocalDescription(offer);
|
||||
} catch (cause) {
|
||||
throw wrap(
|
||||
cause,
|
||||
"RTCPeerConnection.setLocalDescription(offer) failed"
|
||||
);
|
||||
}
|
||||
return offer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a remote offer to `pc`, create an answer, apply it as local
|
||||
* description, and return the answer.
|
||||
*/
|
||||
export async function handleOffer(
|
||||
pc: RTCPeerConnection,
|
||||
offer: RTCSessionDescriptionInit,
|
||||
signal?: AbortSignal
|
||||
): Promise<RTCSessionDescriptionInit> {
|
||||
throwIfAborted(signal, "handleOffer");
|
||||
try {
|
||||
await pc.setRemoteDescription(offer);
|
||||
} catch (cause) {
|
||||
throw wrap(
|
||||
cause,
|
||||
"RTCPeerConnection.setRemoteDescription(offer) failed"
|
||||
);
|
||||
}
|
||||
throwIfAborted(signal, "handleOffer");
|
||||
let answer: RTCSessionDescriptionInit;
|
||||
try {
|
||||
answer = await pc.createAnswer();
|
||||
} catch (cause) {
|
||||
throw wrap(cause, "RTCPeerConnection.createAnswer failed");
|
||||
}
|
||||
throwIfAborted(signal, "handleOffer");
|
||||
try {
|
||||
await pc.setLocalDescription(answer);
|
||||
} catch (cause) {
|
||||
throw wrap(
|
||||
cause,
|
||||
"RTCPeerConnection.setLocalDescription(answer) failed"
|
||||
);
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a remote answer to an outbound offer we already sent.
|
||||
*/
|
||||
export async function handleAnswer(
|
||||
pc: RTCPeerConnection,
|
||||
answer: RTCSessionDescriptionInit,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
throwIfAborted(signal, "handleAnswer");
|
||||
try {
|
||||
await pc.setRemoteDescription(answer);
|
||||
} catch (cause) {
|
||||
throw wrap(
|
||||
cause,
|
||||
"RTCPeerConnection.setRemoteDescription(answer) failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user