mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
feat: Sync to OSS repo
This commit is contained in:
@@ -1,48 +0,0 @@
|
||||
legacy_layout=false
|
||||
|
||||
# common
|
||||
horizontal
|
||||
horizontal_stretch
|
||||
hud_no_margin
|
||||
no_small_font
|
||||
background_alpha=0.66
|
||||
round_corners=0
|
||||
background_color=000000
|
||||
font_size=24
|
||||
position=top-left
|
||||
engine_short_names
|
||||
|
||||
# colors
|
||||
text_color=DFDFDF
|
||||
gpu_color=FF4E00
|
||||
cpu_color=00AA00
|
||||
engine_color=00AA00
|
||||
vram_color=00AA00
|
||||
ram_color=00AA00
|
||||
frametime_color=FF4E00
|
||||
|
||||
# load colors
|
||||
cpu_load_color=DFDFDF,DF964D,DF3D3D
|
||||
gpu_load_color=DFDFDF,DF964D,DF3D3D
|
||||
|
||||
# GPU and VRAM
|
||||
gpu_text=NESTRI
|
||||
gpu_stats
|
||||
gpu_load_change
|
||||
gpu_load_value=70,90
|
||||
|
||||
vram
|
||||
|
||||
# CPU and RAM
|
||||
cpu_text=CPU
|
||||
cpu_stats
|
||||
cpu_load_change
|
||||
cpu_load_value=70,90
|
||||
|
||||
ram
|
||||
|
||||
# FPS and timing
|
||||
fps
|
||||
fps_metrics=0.01
|
||||
|
||||
frame_timing
|
||||
@@ -1,16 +0,0 @@
|
||||
context.modules = [
|
||||
{
|
||||
name = libpipewire-module-loopback
|
||||
args = {
|
||||
node.description = "Loopback"
|
||||
capture.props = {
|
||||
node.name = "Loopback Capture"
|
||||
media.class = "Audio/Sink"
|
||||
}
|
||||
playback.props = {
|
||||
node.name = "Loopback Playback"
|
||||
media.class = "Audio/Source"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,7 +0,0 @@
|
||||
context.properties = {
|
||||
default.clock.rate = 48000
|
||||
default.clock.allowed-rates = [48000]
|
||||
default.clock.min-quantum = 128
|
||||
default.clock.max-quantum = 1024
|
||||
default.clock.quantum = 512
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
"InstallConfigStore"
|
||||
{
|
||||
"Software"
|
||||
{
|
||||
"Valve"
|
||||
{
|
||||
"Steam"
|
||||
{
|
||||
"CompatToolMapping"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"name" "proton_experimental"
|
||||
"config" ""
|
||||
"priority" "75"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"wireplumber.rules": [
|
||||
{
|
||||
"description": "Global audio format and rate for audio nodes",
|
||||
"matches": [
|
||||
{ "media.class": "Audio/Sink" },
|
||||
{ "media.class": "Audio/Source" }
|
||||
],
|
||||
"apply_properties": {
|
||||
"audio.format": "F32P",
|
||||
"audio.rate": 48000,
|
||||
"audio.channels": 2,
|
||||
"session.suspend-timeout-seconds": 0
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"description": "PulseAudio bridge specific tweaks",
|
||||
"matches": [
|
||||
{ "node.name": "pulse_sink" },
|
||||
{ "node.name": "pulse_source" }
|
||||
],
|
||||
"apply_properties": {
|
||||
"pulse.min.req": 128,
|
||||
"pulse.max.req": 1024,
|
||||
"pulse.idle.timeout": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
.idea/
|
||||
dist/
|
||||
node_modules/
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "@nestri/input",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@bufbuild/buf": "^1.59.0",
|
||||
"@bufbuild/protoc-gen-es": "^2.10.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^2.10.0",
|
||||
"@chainsafe/libp2p-noise": "^17.0.0",
|
||||
"@chainsafe/libp2p-quic": "^1.1.3",
|
||||
"@chainsafe/libp2p-yamux": "^8.0.1",
|
||||
"@libp2p/identify": "^4.0.5",
|
||||
"@libp2p/interface": "^3.0.2",
|
||||
"@libp2p/ping": "^3.0.5",
|
||||
"@libp2p/websockets": "^10.0.6",
|
||||
"@libp2p/webtransport": "^6.0.7",
|
||||
"@libp2p/utils": "^7.0.5",
|
||||
"@multiformats/multiaddr": "^13.0.1",
|
||||
"libp2p": "^3.0.6",
|
||||
"uint8arraylist": "^2.4.8"
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
export const keyCodeToLinuxEventCode: { [key: string]: number } = {
|
||||
KeyA: 30,
|
||||
KeyB: 48,
|
||||
KeyC: 46,
|
||||
KeyD: 32,
|
||||
KeyE: 18,
|
||||
KeyF: 33,
|
||||
KeyG: 34,
|
||||
KeyH: 35,
|
||||
KeyI: 23,
|
||||
KeyJ: 36,
|
||||
KeyK: 37,
|
||||
KeyL: 38,
|
||||
KeyM: 50,
|
||||
KeyN: 49,
|
||||
KeyO: 24,
|
||||
KeyP: 25,
|
||||
KeyQ: 16,
|
||||
KeyR: 19,
|
||||
KeyS: 31,
|
||||
KeyT: 20,
|
||||
KeyU: 22,
|
||||
KeyV: 47,
|
||||
KeyW: 17,
|
||||
KeyX: 45,
|
||||
KeyY: 21,
|
||||
KeyZ: 44,
|
||||
Digit1: 2,
|
||||
Digit2: 3,
|
||||
Digit3: 4,
|
||||
Digit4: 5,
|
||||
Digit5: 6,
|
||||
Digit6: 7,
|
||||
Digit7: 8,
|
||||
Digit8: 9,
|
||||
Digit9: 10,
|
||||
Digit0: 11,
|
||||
Enter: 28,
|
||||
Escape: 1,
|
||||
Backspace: 14,
|
||||
Tab: 15,
|
||||
Space: 57,
|
||||
Minus: 12,
|
||||
Equal: 13,
|
||||
BracketLeft: 26,
|
||||
BracketRight: 27,
|
||||
Backslash: 43,
|
||||
Semicolon: 39,
|
||||
Quote: 40,
|
||||
Backquote: 41,
|
||||
Comma: 51,
|
||||
Period: 52,
|
||||
Slash: 53,
|
||||
CapsLock: 58,
|
||||
F1: 59,
|
||||
F2: 60,
|
||||
F3: 61,
|
||||
F4: 62,
|
||||
F5: 63,
|
||||
F6: 64,
|
||||
F7: 65,
|
||||
F8: 66,
|
||||
F9: 67,
|
||||
F10: 68,
|
||||
F11: 87,
|
||||
F12: 88,
|
||||
Insert: 110,
|
||||
Delete: 111,
|
||||
ArrowUp: 103,
|
||||
ArrowDown: 108,
|
||||
ArrowLeft: 105,
|
||||
ArrowRight: 106,
|
||||
Home: 102,
|
||||
End: 107,
|
||||
PageUp: 104,
|
||||
PageDown: 109,
|
||||
NumLock: 69,
|
||||
ScrollLock: 70,
|
||||
Pause: 119,
|
||||
Numpad0: 82,
|
||||
Numpad1: 79,
|
||||
Numpad2: 80,
|
||||
Numpad3: 81,
|
||||
Numpad4: 75,
|
||||
Numpad5: 76,
|
||||
Numpad6: 77,
|
||||
Numpad7: 71,
|
||||
Numpad8: 72,
|
||||
Numpad9: 73,
|
||||
NumpadDivide: 98,
|
||||
NumpadMultiply: 55,
|
||||
NumpadSubtract: 74,
|
||||
NumpadAdd: 78,
|
||||
NumpadEnter: 96,
|
||||
NumpadDecimal: 83,
|
||||
ControlLeft: 29,
|
||||
ControlRight: 97,
|
||||
ShiftLeft: 42,
|
||||
ShiftRight: 54,
|
||||
AltLeft: 56,
|
||||
AltRight: 100,
|
||||
//'MetaLeft': 125, // Disabled as will break input
|
||||
//'MetaRight': 126, // Disabled as will break input
|
||||
ContextMenu: 127,
|
||||
};
|
||||
|
||||
export const mouseButtonToLinuxEventCode: { [button: number]: number } = {
|
||||
0: 272,
|
||||
2: 273,
|
||||
1: 274,
|
||||
3: 275,
|
||||
4: 276,
|
||||
};
|
||||
|
||||
export const controllerButtonToLinuxEventCode: { [button: number]: number } = {
|
||||
0: 0x130,
|
||||
1: 0x131,
|
||||
2: 0x134,
|
||||
3: 0x133,
|
||||
4: 0x136,
|
||||
5: 0x137,
|
||||
6: 0x138,
|
||||
7: 0x139,
|
||||
8: 0x13a,
|
||||
9: 0x13b,
|
||||
10: 0x13d,
|
||||
11: 0x13e,
|
||||
12: 0x220,
|
||||
13: 0x221,
|
||||
14: 0x222,
|
||||
15: 0x223,
|
||||
16: 0x13c,
|
||||
};
|
||||
@@ -1,531 +0,0 @@
|
||||
import { controllerButtonToLinuxEventCode } from "./codes";
|
||||
import { WebRTCStream } from "./webrtc-stream";
|
||||
import {
|
||||
ProtoControllerAttachSchema,
|
||||
ProtoControllerDetachSchema,
|
||||
ProtoControllerStateBatchSchema,
|
||||
ProtoControllerStateBatch,
|
||||
ProtoControllerRumble,
|
||||
} from "./proto/types_pb";
|
||||
import { create, toBinary, fromBinary } from "@bufbuild/protobuf";
|
||||
import { createMessage } from "./utils";
|
||||
import { ProtoMessageSchema } from "./proto/messages_pb";
|
||||
|
||||
interface Props {
|
||||
webrtc: WebRTCStream;
|
||||
e: GamepadEvent;
|
||||
}
|
||||
|
||||
interface GamepadState {
|
||||
previousButtonState: Map<number, boolean>;
|
||||
buttonState: Map<number, boolean>;
|
||||
leftTrigger: number;
|
||||
rightTrigger: number;
|
||||
leftX: number;
|
||||
leftY: number;
|
||||
rightX: number;
|
||||
rightY: number;
|
||||
dpadX: number;
|
||||
dpadY: number;
|
||||
}
|
||||
|
||||
enum PollState {
|
||||
IDLE,
|
||||
RUNNING,
|
||||
}
|
||||
|
||||
export class Controller {
|
||||
protected wrtc: WebRTCStream;
|
||||
protected connected: boolean = false;
|
||||
protected gamepad: Gamepad | null = null;
|
||||
protected state: GamepadState = {
|
||||
previousButtonState: new Map<number, boolean>(),
|
||||
buttonState: new Map<number, boolean>(),
|
||||
leftTrigger: 0,
|
||||
rightTrigger: 0,
|
||||
leftX: 0,
|
||||
leftY: 0,
|
||||
rightX: 0,
|
||||
rightY: 0,
|
||||
dpadX: 0,
|
||||
dpadY: 0,
|
||||
};
|
||||
// TODO: As user configurable, set quite low now for decent controllers (not Nintendo ones :P)
|
||||
protected stickDeadzone: number = 2048; // 2048 / 32768 = ~0.06 (6% of stick range)
|
||||
|
||||
// Polling configuration
|
||||
private readonly FULL_RATE_MS = 10; // 100 UPS
|
||||
private readonly IDLE_THRESHOLD = 100; // ms before considering idle/hands off controller
|
||||
private readonly FULL_INTERVAL = 250; // ms before sending full state occassionally, to verify inputs are synced
|
||||
|
||||
// Polling state
|
||||
private pollingState: PollState = PollState.IDLE;
|
||||
private lastInputTime: number = Date.now();
|
||||
private lastFullTime: number = Date.now();
|
||||
private pollInterval: any = null;
|
||||
|
||||
// Controller batch vars
|
||||
private sequence: number = 0;
|
||||
private readonly CHANGED_BUTTONS_STATE = 1 << 0;
|
||||
private readonly CHANGED_LEFT_STICK_X = 1 << 1;
|
||||
private readonly CHANGED_LEFT_STICK_Y = 1 << 2;
|
||||
private readonly CHANGED_RIGHT_STICK_X = 1 << 3;
|
||||
private readonly CHANGED_RIGHT_STICK_Y = 1 << 4;
|
||||
private readonly CHANGED_LEFT_TRIGGER = 1 << 5;
|
||||
private readonly CHANGED_RIGHT_TRIGGER = 1 << 6;
|
||||
private readonly CHANGED_DPAD_X = 1 << 7;
|
||||
private readonly CHANGED_DPAD_Y = 1 << 8;
|
||||
|
||||
private _dcHandler: ((data: ArrayBuffer) => void) | null = null;
|
||||
|
||||
constructor({ webrtc, e }: Props) {
|
||||
this.wrtc = webrtc;
|
||||
|
||||
// Get vendor of gamepad from id string (i.e. "... Vendor: 054c Product: 09cc")
|
||||
const vendorMatch = e.gamepad.id.match(/Vendor:\s?([0-9a-fA-F]{4})/);
|
||||
const vendorId = vendorMatch ? vendorMatch[1].toLowerCase() : "unknown";
|
||||
// Get product id of gamepad from id string
|
||||
const productMatch = e.gamepad.id.match(/Product:\s?([0-9a-fA-F]{4})/);
|
||||
const productId = productMatch ? productMatch[1].toLowerCase() : "unknown";
|
||||
|
||||
// Listen to datachannel events from server
|
||||
this._dcHandler = (data: ArrayBuffer) => {
|
||||
if (!this.connected) return;
|
||||
try {
|
||||
// First decode the wrapper message
|
||||
const uint8Data = new Uint8Array(data);
|
||||
const messageWrapper = fromBinary(ProtoMessageSchema, uint8Data);
|
||||
|
||||
if (messageWrapper.payload.case === "controllerRumble") {
|
||||
this.rumbleCallback(messageWrapper.payload.value);
|
||||
} else if (messageWrapper.payload.case === "controllerAttach") {
|
||||
if (this.gamepad) return; // already attached
|
||||
const attachMsg = messageWrapper.payload.value;
|
||||
// Gamepad connected succesfully
|
||||
this.gamepad = e.gamepad;
|
||||
console.log(
|
||||
`Gamepad connected: ${e.gamepad.id}, local slot ${e.gamepad.index}, msg: ${attachMsg.sessionSlot}`,
|
||||
);
|
||||
this.run();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error decoding datachannel message:", err);
|
||||
}
|
||||
};
|
||||
this.wrtc.addDataChannelCallback(this._dcHandler);
|
||||
|
||||
const attachMsg = createMessage(
|
||||
create(ProtoControllerAttachSchema, {
|
||||
id: this.vendor_id_to_controller(vendorId, productId),
|
||||
sessionSlot: e.gamepad.index,
|
||||
sessionId: this.wrtc.getSessionID(),
|
||||
}),
|
||||
"controllerInput",
|
||||
);
|
||||
this.wrtc.sendBinary(toBinary(ProtoMessageSchema, attachMsg));
|
||||
|
||||
this.run();
|
||||
}
|
||||
|
||||
public getSlot(): number {
|
||||
return this.gamepad.index;
|
||||
}
|
||||
|
||||
// Maps vendor id and product id to supported controller type
|
||||
// Currently supported: Sony (ps4, ps5), Microsoft (xbox360, xboxone), Nintendo (switchpro)
|
||||
// Default fallback to xbox360
|
||||
private vendor_id_to_controller(vendorId: string, productId: string): string {
|
||||
switch (vendorId) {
|
||||
case "054c": // Sony
|
||||
switch (productId) {
|
||||
case "0ce6":
|
||||
return "ps5";
|
||||
case "05c4":
|
||||
case "09cc":
|
||||
return "ps4";
|
||||
default:
|
||||
return "ps4"; // default to ps4
|
||||
}
|
||||
case "045e": // Microsoft
|
||||
switch (productId) {
|
||||
case "02d1":
|
||||
case "02dd":
|
||||
return "xboxone";
|
||||
case "028e":
|
||||
return "xbox360";
|
||||
default:
|
||||
return "xbox360"; // default to xbox360
|
||||
}
|
||||
case "057e": // Nintendo
|
||||
switch (productId) {
|
||||
case "2009":
|
||||
case "200e":
|
||||
return "switchpro";
|
||||
default:
|
||||
return "switchpro"; // default to switchpro
|
||||
}
|
||||
default: {
|
||||
return "xbox360";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private remapFromTo(
|
||||
value: number,
|
||||
fromMin: number,
|
||||
fromMax: number,
|
||||
toMin: number,
|
||||
toMax: number,
|
||||
) {
|
||||
return ((value - fromMin) * (toMax - toMin)) / (fromMax - fromMin) + toMin;
|
||||
}
|
||||
|
||||
private restartPolling() {
|
||||
// Clear existing interval
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
|
||||
// Restart with active polling
|
||||
this.pollingState = PollState.RUNNING;
|
||||
this.lastInputTime = Date.now();
|
||||
|
||||
// Start interval
|
||||
this.pollInterval = setInterval(
|
||||
() => this.pollGamepad(),
|
||||
this.FULL_RATE_MS,
|
||||
);
|
||||
}
|
||||
|
||||
private pollGamepad() {
|
||||
if (!this.connected || !this.gamepad) return;
|
||||
|
||||
const gamepads = navigator.getGamepads();
|
||||
if (!gamepads[this.gamepad.index]) return;
|
||||
|
||||
this.gamepad = gamepads[this.gamepad.index];
|
||||
|
||||
// Collect state changes
|
||||
const changedFields = this.collectStateChanges();
|
||||
|
||||
// Send batched changes update if there's changes
|
||||
if (changedFields > 0) {
|
||||
let send_type = 1;
|
||||
const timeSinceFull = Date.now() - this.lastFullTime;
|
||||
if (timeSinceFull > this.FULL_INTERVAL) {
|
||||
send_type = 0;
|
||||
this.lastFullTime = Date.now();
|
||||
}
|
||||
|
||||
this.sendBatchedState(changedFields, send_type);
|
||||
this.lastInputTime = Date.now();
|
||||
if (this.pollingState !== PollState.RUNNING) {
|
||||
this.pollingState = PollState.RUNNING;
|
||||
}
|
||||
}
|
||||
|
||||
const timeSinceInput = Date.now() - this.lastInputTime;
|
||||
if (timeSinceInput > this.IDLE_THRESHOLD) {
|
||||
// Changing from running to idle..
|
||||
if (this.pollingState === PollState.RUNNING) {
|
||||
// Send full state on idle assumption
|
||||
this.sendBatchedState(0xff, 0);
|
||||
this.pollingState = PollState.IDLE;
|
||||
}
|
||||
}
|
||||
|
||||
this.state.buttonState.forEach((b, i) =>
|
||||
this.state.previousButtonState.set(i, b),
|
||||
);
|
||||
}
|
||||
|
||||
private collectStateChanges(): number {
|
||||
let changedFields = 0;
|
||||
|
||||
// Collect analog values
|
||||
const leftTrigger = Math.round(
|
||||
this.remapFromTo(
|
||||
this.gamepad.buttons[6]?.value ?? 0,
|
||||
0,
|
||||
1,
|
||||
-32768,
|
||||
32767,
|
||||
),
|
||||
);
|
||||
const rightTrigger = Math.round(
|
||||
this.remapFromTo(
|
||||
this.gamepad.buttons[7]?.value ?? 0,
|
||||
0,
|
||||
1,
|
||||
-32768,
|
||||
32767,
|
||||
),
|
||||
);
|
||||
|
||||
const leftX = this.remapFromTo(
|
||||
this.gamepad.axes[0] ?? 0,
|
||||
-1,
|
||||
1,
|
||||
-32768,
|
||||
32767,
|
||||
);
|
||||
const leftY = this.remapFromTo(
|
||||
this.gamepad.axes[1] ?? 0,
|
||||
-1,
|
||||
1,
|
||||
-32768,
|
||||
32767,
|
||||
);
|
||||
const sendLeftX =
|
||||
Math.abs(leftX) > this.stickDeadzone ? Math.round(leftX) : 0;
|
||||
const sendLeftY =
|
||||
Math.abs(leftY) > this.stickDeadzone ? Math.round(leftY) : 0;
|
||||
|
||||
const rightX = this.remapFromTo(
|
||||
this.gamepad.axes[2] ?? 0,
|
||||
-1,
|
||||
1,
|
||||
-32768,
|
||||
32767,
|
||||
);
|
||||
const rightY = this.remapFromTo(
|
||||
this.gamepad.axes[3] ?? 0,
|
||||
-1,
|
||||
1,
|
||||
-32768,
|
||||
32767,
|
||||
);
|
||||
const sendRightX =
|
||||
Math.abs(rightX) > this.stickDeadzone ? Math.round(rightX) : 0;
|
||||
const sendRightY =
|
||||
Math.abs(rightY) > this.stickDeadzone ? Math.round(rightY) : 0;
|
||||
|
||||
const dpadX =
|
||||
(this.gamepad.buttons[14]?.pressed ? -1 : 0) +
|
||||
(this.gamepad.buttons[15]?.pressed ? 1 : 0);
|
||||
const dpadY =
|
||||
(this.gamepad.buttons[12]?.pressed ? -1 : 0) +
|
||||
(this.gamepad.buttons[13]?.pressed ? 1 : 0);
|
||||
|
||||
// Check what changed
|
||||
for (let i = 0; i < this.gamepad.buttons.length; i++) {
|
||||
if (i >= 6 && i <= 7) continue; // Skip triggers
|
||||
if (i >= 12 && i <= 15) continue; // Skip d-pad
|
||||
if (this.state.buttonState.get(i) !== this.gamepad.buttons[i].pressed) {
|
||||
changedFields |= this.CHANGED_BUTTONS_STATE;
|
||||
}
|
||||
this.state.buttonState.set(i, this.gamepad.buttons[i].pressed);
|
||||
}
|
||||
if (leftTrigger !== this.state.leftTrigger) {
|
||||
changedFields |= this.CHANGED_LEFT_TRIGGER;
|
||||
}
|
||||
this.state.leftTrigger = leftTrigger;
|
||||
if (rightTrigger !== this.state.rightTrigger) {
|
||||
changedFields |= this.CHANGED_RIGHT_TRIGGER;
|
||||
}
|
||||
this.state.rightTrigger = rightTrigger;
|
||||
if (sendLeftX !== this.state.leftX) {
|
||||
changedFields |= this.CHANGED_LEFT_STICK_X;
|
||||
}
|
||||
this.state.leftX = sendLeftX;
|
||||
if (sendLeftY !== this.state.leftY) {
|
||||
changedFields |= this.CHANGED_LEFT_STICK_Y;
|
||||
}
|
||||
this.state.leftY = sendLeftY;
|
||||
if (sendRightX !== this.state.rightX) {
|
||||
changedFields |= this.CHANGED_RIGHT_STICK_X;
|
||||
}
|
||||
this.state.rightX = sendRightX;
|
||||
if (sendRightY !== this.state.rightY) {
|
||||
changedFields |= this.CHANGED_RIGHT_STICK_Y;
|
||||
}
|
||||
this.state.rightY = sendRightY;
|
||||
if (dpadX !== this.state.dpadX) {
|
||||
changedFields |= this.CHANGED_DPAD_X;
|
||||
}
|
||||
this.state.dpadX = dpadX;
|
||||
if (dpadY !== this.state.dpadY) {
|
||||
changedFields |= this.CHANGED_DPAD_Y;
|
||||
}
|
||||
this.state.dpadY = dpadY;
|
||||
|
||||
return changedFields;
|
||||
}
|
||||
|
||||
private sendBatchedState(changedFields: number, updateType: number) {
|
||||
// @ts-ignore
|
||||
let message: ProtoControllerStateBatch = {
|
||||
sessionSlot: this.gamepad.index,
|
||||
sessionId: this.wrtc.getSessionID(),
|
||||
updateType: updateType,
|
||||
sequence: this.sequence++,
|
||||
};
|
||||
|
||||
// For FULL_STATE, include everything
|
||||
if (updateType === 0) {
|
||||
message.changedFields = 0xff;
|
||||
|
||||
message.buttonChangedMask = Object.fromEntries(
|
||||
Array.from(this.state.buttonState)
|
||||
.map(
|
||||
([key, value]) =>
|
||||
[this.controllerButtonToVirtualKeyCode(key), value] as const,
|
||||
)
|
||||
.filter(([code]) => code !== undefined),
|
||||
);
|
||||
message.leftStickX = this.state.leftX;
|
||||
message.leftStickY = this.state.leftY;
|
||||
message.rightStickX = this.state.rightX;
|
||||
message.rightStickY = this.state.rightY;
|
||||
message.leftTrigger = this.state.leftTrigger;
|
||||
message.rightTrigger = this.state.rightTrigger;
|
||||
message.dpadX = this.state.dpadX;
|
||||
message.dpadY = this.state.dpadY;
|
||||
}
|
||||
// For DELTA, only include changed fields
|
||||
else {
|
||||
message.changedFields = changedFields;
|
||||
|
||||
if (changedFields & this.CHANGED_BUTTONS_STATE) {
|
||||
const currentStateMap = this.state.buttonState;
|
||||
const previousStateMap = this.state.previousButtonState;
|
||||
const allKeys = new Set([
|
||||
// @ts-ignore
|
||||
...currentStateMap.keys(),
|
||||
// @ts-ignore
|
||||
...previousStateMap.keys(),
|
||||
]);
|
||||
message.buttonChangedMask = Object.fromEntries(
|
||||
Array.from(allKeys)
|
||||
.filter((key) => {
|
||||
const newState = currentStateMap.get(key);
|
||||
const oldState = previousStateMap.get(key);
|
||||
return newState !== oldState;
|
||||
})
|
||||
.map((key) => {
|
||||
const newValue = currentStateMap.get(key) ?? false;
|
||||
return [
|
||||
this.controllerButtonToVirtualKeyCode(key),
|
||||
newValue,
|
||||
] as const;
|
||||
})
|
||||
.filter(([code]) => code !== undefined),
|
||||
);
|
||||
}
|
||||
if (changedFields & this.CHANGED_LEFT_STICK_X) {
|
||||
message.leftStickX = this.state.leftX;
|
||||
}
|
||||
if (changedFields & this.CHANGED_LEFT_STICK_Y) {
|
||||
message.leftStickY = this.state.leftY;
|
||||
}
|
||||
if (changedFields & this.CHANGED_RIGHT_STICK_X) {
|
||||
message.rightStickX = this.state.rightX;
|
||||
}
|
||||
if (changedFields & this.CHANGED_RIGHT_STICK_Y) {
|
||||
message.rightStickY = this.state.rightY;
|
||||
}
|
||||
if (changedFields & this.CHANGED_LEFT_TRIGGER) {
|
||||
message.leftTrigger = this.state.leftTrigger;
|
||||
}
|
||||
if (changedFields & this.CHANGED_RIGHT_TRIGGER) {
|
||||
message.rightTrigger = this.state.rightTrigger;
|
||||
}
|
||||
if (changedFields & this.CHANGED_DPAD_X) {
|
||||
message.dpadX = this.state.dpadX;
|
||||
}
|
||||
if (changedFields & this.CHANGED_DPAD_Y) {
|
||||
message.dpadY = this.state.dpadY;
|
||||
}
|
||||
}
|
||||
|
||||
// Send message
|
||||
const batchMessage = createMessage(
|
||||
create(
|
||||
ProtoControllerStateBatchSchema,
|
||||
message as ProtoControllerStateBatch,
|
||||
),
|
||||
"controllerInput",
|
||||
);
|
||||
this.wrtc.sendBinary(toBinary(ProtoMessageSchema, batchMessage));
|
||||
}
|
||||
|
||||
public run() {
|
||||
if (this.connected) this.stop();
|
||||
|
||||
this.connected = true;
|
||||
|
||||
// Start with active polling
|
||||
this.restartPolling();
|
||||
}
|
||||
|
||||
public stop() {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.stop();
|
||||
// Remove callback
|
||||
if (this._dcHandler !== null) {
|
||||
this.wrtc.removeDataChannelCallback(this._dcHandler);
|
||||
this._dcHandler = null;
|
||||
}
|
||||
if (this.gamepad) {
|
||||
// Gamepad disconnected
|
||||
const detachMsg = createMessage(
|
||||
create(ProtoControllerDetachSchema, {
|
||||
sessionSlot: this.gamepad.index,
|
||||
}),
|
||||
"controllerInput",
|
||||
);
|
||||
this.wrtc.sendBinary(toBinary(ProtoMessageSchema, detachMsg));
|
||||
}
|
||||
}
|
||||
|
||||
private controllerButtonToVirtualKeyCode(code: number): number | undefined {
|
||||
return controllerButtonToLinuxEventCode[code] || undefined;
|
||||
}
|
||||
|
||||
private rumbleCallback(rumbleMsg: ProtoControllerRumble) {
|
||||
if (!this.connected || !this.gamepad) return;
|
||||
|
||||
// Check if this rumble is for us
|
||||
if (
|
||||
rumbleMsg.sessionId !== this.wrtc.getSessionID() ||
|
||||
rumbleMsg.sessionSlot !== this.gamepad.index
|
||||
)
|
||||
return;
|
||||
|
||||
// Trigger actual rumble
|
||||
// Need to remap from 0-65535 to 0.0-1.0 ranges
|
||||
const clampedLowFreq = Math.max(0, Math.min(65535, rumbleMsg.lowFrequency));
|
||||
const rumbleLowFreq = this.remapFromTo(clampedLowFreq, 0, 65535, 0.0, 1.0);
|
||||
const clampedHighFreq = Math.max(
|
||||
0,
|
||||
Math.min(65535, rumbleMsg.highFrequency),
|
||||
);
|
||||
const rumbleHighFreq = this.remapFromTo(
|
||||
clampedHighFreq,
|
||||
0,
|
||||
65535,
|
||||
0.0,
|
||||
1.0,
|
||||
);
|
||||
// Cap to valid range (max 5000)
|
||||
const rumbleDuration = Math.max(0, Math.min(5000, rumbleMsg.duration));
|
||||
if (this.gamepad.vibrationActuator) {
|
||||
this.gamepad.vibrationActuator
|
||||
.playEffect("dual-rumble", {
|
||||
startDelay: 0,
|
||||
duration: rumbleDuration,
|
||||
weakMagnitude: rumbleLowFreq,
|
||||
strongMagnitude: rumbleHighFreq,
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from "./keyboard"
|
||||
export * from "./mouse"
|
||||
export * from "./controller"
|
||||
export * from "./webrtc-stream"
|
||||
@@ -1,76 +0,0 @@
|
||||
import { keyCodeToLinuxEventCode } from "./codes";
|
||||
import { WebRTCStream } from "./webrtc-stream";
|
||||
import { ProtoKeyDownSchema, ProtoKeyUpSchema } from "./proto/types_pb";
|
||||
import { create, toBinary } from "@bufbuild/protobuf";
|
||||
import { createMessage } from "./utils";
|
||||
import { ProtoMessageSchema } from "./proto/messages_pb";
|
||||
|
||||
interface Props {
|
||||
webrtc: WebRTCStream;
|
||||
}
|
||||
|
||||
export class Keyboard {
|
||||
protected wrtc: WebRTCStream;
|
||||
protected connected!: boolean;
|
||||
|
||||
// Store references to event listeners
|
||||
private readonly keydownListener: (e: KeyboardEvent) => void;
|
||||
private readonly keyupListener: (e: KeyboardEvent) => void;
|
||||
|
||||
constructor({ webrtc }: Props) {
|
||||
this.wrtc = webrtc;
|
||||
this.keydownListener = this.createKeyboardListener((e: any) =>
|
||||
create(ProtoKeyDownSchema, {
|
||||
key: this.keyToVirtualKeyCode(e.code),
|
||||
}),
|
||||
);
|
||||
this.keyupListener = this.createKeyboardListener((e: any) =>
|
||||
create(ProtoKeyUpSchema, {
|
||||
key: this.keyToVirtualKeyCode(e.code),
|
||||
}),
|
||||
);
|
||||
this.run();
|
||||
}
|
||||
|
||||
private run() {
|
||||
if (this.connected) this.stop();
|
||||
|
||||
this.connected = true;
|
||||
document.addEventListener("keydown", this.keydownListener);
|
||||
document.addEventListener("keyup", this.keyupListener);
|
||||
}
|
||||
|
||||
private stop() {
|
||||
document.removeEventListener("keydown", this.keydownListener);
|
||||
document.removeEventListener("keyup", this.keyupListener);
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
// Helper function to create and return mouse listeners
|
||||
private createKeyboardListener(
|
||||
dataCreator: (e: Event) => any,
|
||||
): (e: Event) => void {
|
||||
return (e: Event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Prevent repeated key events from being sent (important for games)
|
||||
if ((e as any).repeat) return;
|
||||
|
||||
const data = dataCreator(e as any);
|
||||
|
||||
const message = createMessage(data, "input");
|
||||
this.wrtc.sendBinary(toBinary(ProtoMessageSchema, message));
|
||||
};
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.stop();
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
private keyToVirtualKeyCode(code: string) {
|
||||
// Treat Home key as Escape - TODO: Make user-configurable
|
||||
if (code === "Home") return 1;
|
||||
return keyCodeToLinuxEventCode[code] || 0;
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
type TimestampEntry = {
|
||||
stage: string;
|
||||
time: Date;
|
||||
};
|
||||
|
||||
export class LatencyTracker {
|
||||
sequence_id: string;
|
||||
timestamps: TimestampEntry[];
|
||||
|
||||
constructor(sequence_id: string, timestamps: TimestampEntry[] = []) {
|
||||
this.sequence_id = sequence_id;
|
||||
this.timestamps = timestamps;
|
||||
}
|
||||
|
||||
addTimestamp(stage: string): void {
|
||||
const timestamp: TimestampEntry = {
|
||||
stage,
|
||||
time: new Date(),
|
||||
};
|
||||
this.timestamps.push(timestamp);
|
||||
}
|
||||
|
||||
// Calculates the total time between the first and last recorded timestamps.
|
||||
getTotalLatency(): number {
|
||||
if (this.timestamps.length < 2) return 0;
|
||||
|
||||
const times = this.timestamps.map((entry) => entry.time.getTime());
|
||||
const minTime = Math.min(...times);
|
||||
const maxTime = Math.max(...times);
|
||||
return maxTime - minTime;
|
||||
}
|
||||
|
||||
toJSON(): Record<string, any> {
|
||||
return {
|
||||
sequence_id: this.sequence_id,
|
||||
timestamps: this.timestamps.map((entry) => ({
|
||||
stage: entry.stage,
|
||||
// Fill nanoseconds with zeros to match the expected format
|
||||
time: entry.time.toISOString().replace(/\.(\d+)Z$/, ".$1000000Z"),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
static fromJSON(json: any): LatencyTracker {
|
||||
const timestamps: TimestampEntry[] = json.timestamps.map((ts: any) => ({
|
||||
stage: ts.stage,
|
||||
time: new Date(ts.time),
|
||||
}));
|
||||
return new LatencyTracker(json.sequence_id, timestamps);
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { WebRTCStream } from "./webrtc-stream";
|
||||
import {
|
||||
ProtoMouseKeyDownSchema,
|
||||
ProtoMouseKeyUpSchema,
|
||||
ProtoMouseMoveSchema,
|
||||
ProtoMouseWheelSchema,
|
||||
} from "./proto/types_pb";
|
||||
import { mouseButtonToLinuxEventCode } from "./codes";
|
||||
import { create, toBinary } from "@bufbuild/protobuf";
|
||||
import { createMessage } from "./utils";
|
||||
import { ProtoMessageSchema } from "./proto/messages_pb";
|
||||
|
||||
interface Props {
|
||||
webrtc: WebRTCStream;
|
||||
canvas: HTMLCanvasElement;
|
||||
}
|
||||
|
||||
export class Mouse {
|
||||
protected wrtc: WebRTCStream;
|
||||
protected canvas: HTMLCanvasElement;
|
||||
protected connected!: boolean;
|
||||
|
||||
private sendInterval = 10; // 100 updates per second
|
||||
|
||||
// Store references to event listeners
|
||||
private readonly mousemoveListener: (e: MouseEvent) => void;
|
||||
private movementX: number = 0;
|
||||
private movementY: number = 0;
|
||||
|
||||
private readonly mousedownListener: (e: MouseEvent) => void;
|
||||
private readonly mouseupListener: (e: MouseEvent) => void;
|
||||
private readonly mousewheelListener: (e: WheelEvent) => void;
|
||||
|
||||
constructor({ webrtc, canvas }: Props) {
|
||||
this.wrtc = webrtc;
|
||||
this.canvas = canvas;
|
||||
|
||||
this.mousemoveListener = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.movementX += e.movementX;
|
||||
this.movementY += e.movementY;
|
||||
};
|
||||
|
||||
this.mousedownListener = this.createMouseListener((e: any) =>
|
||||
create(ProtoMouseKeyDownSchema, {
|
||||
key: this.keyToVirtualKeyCode(e.button),
|
||||
}),
|
||||
);
|
||||
this.mouseupListener = this.createMouseListener((e: any) =>
|
||||
create(ProtoMouseKeyUpSchema, {
|
||||
key: this.keyToVirtualKeyCode(e.button),
|
||||
}),
|
||||
);
|
||||
this.mousewheelListener = this.createMouseListener((e: any) =>
|
||||
create(ProtoMouseWheelSchema, {
|
||||
x: Math.round(e.deltaX),
|
||||
y: Math.round(e.deltaY),
|
||||
}),
|
||||
);
|
||||
|
||||
this.run();
|
||||
this.startProcessing();
|
||||
}
|
||||
|
||||
private run() {
|
||||
//calls all the other functions
|
||||
if (!document.pointerLockElement) {
|
||||
console.log("no pointerlock");
|
||||
if (this.connected) {
|
||||
this.stop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.pointerLockElement == this.canvas) {
|
||||
this.connected = true;
|
||||
this.canvas.addEventListener("mousemove", this.mousemoveListener);
|
||||
this.canvas.addEventListener("mousedown", this.mousedownListener);
|
||||
this.canvas.addEventListener("mouseup", this.mouseupListener);
|
||||
this.canvas.addEventListener("wheel", this.mousewheelListener);
|
||||
} else {
|
||||
if (this.connected) {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private stop() {
|
||||
this.canvas.removeEventListener("mousemove", this.mousemoveListener);
|
||||
this.canvas.removeEventListener("mousedown", this.mousedownListener);
|
||||
this.canvas.removeEventListener("mouseup", this.mouseupListener);
|
||||
this.canvas.removeEventListener("wheel", this.mousewheelListener);
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
private startProcessing() {
|
||||
setInterval(() => {
|
||||
if (this.connected) {
|
||||
this.sendAggregatedMouseMove();
|
||||
this.movementX = 0;
|
||||
this.movementY = 0;
|
||||
}
|
||||
}, this.sendInterval);
|
||||
}
|
||||
|
||||
private sendAggregatedMouseMove() {
|
||||
const data = create(ProtoMouseMoveSchema, {
|
||||
x: Math.round(this.movementX),
|
||||
y: Math.round(this.movementY),
|
||||
});
|
||||
|
||||
const message = createMessage(data, "input");
|
||||
this.wrtc.sendBinary(toBinary(ProtoMessageSchema, message));
|
||||
}
|
||||
|
||||
// Helper function to create and return mouse listeners
|
||||
private createMouseListener(
|
||||
dataCreator: (e: Event) => any,
|
||||
): (e: Event) => void {
|
||||
return (e: Event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const data = dataCreator(e as any);
|
||||
|
||||
const message = createMessage(data, "input");
|
||||
this.wrtc.sendBinary(toBinary(ProtoMessageSchema, message));
|
||||
};
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
document.exitPointerLock();
|
||||
this.stop();
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
private keyToVirtualKeyCode(code: number) {
|
||||
return mouseButtonToLinuxEventCode[code] || undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// @generated by protoc-gen-es v2.10.1 with parameter "target=ts"
|
||||
// @generated from file latency_tracker.proto (package proto, syntax proto3)
|
||||
/* eslint-disable */
|
||||
|
||||
import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
|
||||
import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
|
||||
import type { Timestamp } from "@bufbuild/protobuf/wkt";
|
||||
import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt";
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
|
||||
/**
|
||||
* Describes the file latency_tracker.proto.
|
||||
*/
|
||||
export const file_latency_tracker: GenFile = /*@__PURE__*/
|
||||
fileDesc("ChVsYXRlbmN5X3RyYWNrZXIucHJvdG8SBXByb3RvIk4KE1Byb3RvVGltZXN0YW1wRW50cnkSDQoFc3RhZ2UYASABKAkSKAoEdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiWgoTUHJvdG9MYXRlbmN5VHJhY2tlchITCgtzZXF1ZW5jZV9pZBgBIAEoCRIuCgp0aW1lc3RhbXBzGAIgAygLMhoucHJvdG8uUHJvdG9UaW1lc3RhbXBFbnRyeUIWWhRyZWxheS9pbnRlcm5hbC9wcm90b2IGcHJvdG8z", [file_google_protobuf_timestamp]);
|
||||
|
||||
/**
|
||||
* @generated from message proto.ProtoTimestampEntry
|
||||
*/
|
||||
export type ProtoTimestampEntry = Message<"proto.ProtoTimestampEntry"> & {
|
||||
/**
|
||||
* @generated from field: string stage = 1;
|
||||
*/
|
||||
stage: string;
|
||||
|
||||
/**
|
||||
* @generated from field: google.protobuf.Timestamp time = 2;
|
||||
*/
|
||||
time?: Timestamp;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.ProtoTimestampEntry.
|
||||
* Use `create(ProtoTimestampEntrySchema)` to create a new message.
|
||||
*/
|
||||
export const ProtoTimestampEntrySchema: GenMessage<ProtoTimestampEntry> = /*@__PURE__*/
|
||||
messageDesc(file_latency_tracker, 0);
|
||||
|
||||
/**
|
||||
* @generated from message proto.ProtoLatencyTracker
|
||||
*/
|
||||
export type ProtoLatencyTracker = Message<"proto.ProtoLatencyTracker"> & {
|
||||
/**
|
||||
* @generated from field: string sequence_id = 1;
|
||||
*/
|
||||
sequenceId: string;
|
||||
|
||||
/**
|
||||
* @generated from field: repeated proto.ProtoTimestampEntry timestamps = 2;
|
||||
*/
|
||||
timestamps: ProtoTimestampEntry[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.ProtoLatencyTracker.
|
||||
* Use `create(ProtoLatencyTrackerSchema)` to create a new message.
|
||||
*/
|
||||
export const ProtoLatencyTrackerSchema: GenMessage<ProtoLatencyTracker> = /*@__PURE__*/
|
||||
messageDesc(file_latency_tracker, 1);
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
// @generated by protoc-gen-es v2.10.1 with parameter "target=ts"
|
||||
// @generated from file messages.proto (package proto, syntax proto3)
|
||||
/* eslint-disable */
|
||||
|
||||
import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
|
||||
import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
|
||||
import type { ProtoClientDisconnected, ProtoClientRequestRoomStream, ProtoControllerAttach, ProtoControllerDetach, ProtoControllerRumble, ProtoControllerStateBatch, ProtoICE, ProtoKeyDown, ProtoKeyUp, ProtoMouseKeyDown, ProtoMouseKeyUp, ProtoMouseMove, ProtoMouseMoveAbs, ProtoMouseWheel, ProtoRaw, ProtoSDP, ProtoServerPushStream } from "./types_pb";
|
||||
import { file_types } from "./types_pb";
|
||||
import type { ProtoLatencyTracker } from "./latency_tracker_pb";
|
||||
import { file_latency_tracker } from "./latency_tracker_pb";
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
|
||||
/**
|
||||
* Describes the file messages.proto.
|
||||
*/
|
||||
export const file_messages: GenFile = /*@__PURE__*/
|
||||
fileDesc("Cg5tZXNzYWdlcy5wcm90bxIFcHJvdG8iVQoQUHJvdG9NZXNzYWdlQmFzZRIUCgxwYXlsb2FkX3R5cGUYASABKAkSKwoHbGF0ZW5jeRgCIAEoCzIaLnByb3RvLlByb3RvTGF0ZW5jeVRyYWNrZXIipQcKDFByb3RvTWVzc2FnZRItCgxtZXNzYWdlX2Jhc2UYASABKAsyFy5wcm90by5Qcm90b01lc3NhZ2VCYXNlEisKCm1vdXNlX21vdmUYAiABKAsyFS5wcm90by5Qcm90b01vdXNlTW92ZUgAEjIKDm1vdXNlX21vdmVfYWJzGAMgASgLMhgucHJvdG8uUHJvdG9Nb3VzZU1vdmVBYnNIABItCgttb3VzZV93aGVlbBgEIAEoCzIWLnByb3RvLlByb3RvTW91c2VXaGVlbEgAEjIKDm1vdXNlX2tleV9kb3duGAUgASgLMhgucHJvdG8uUHJvdG9Nb3VzZUtleURvd25IABIuCgxtb3VzZV9rZXlfdXAYBiABKAsyFi5wcm90by5Qcm90b01vdXNlS2V5VXBIABInCghrZXlfZG93bhgHIAEoCzITLnByb3RvLlByb3RvS2V5RG93bkgAEiMKBmtleV91cBgIIAEoCzIRLnByb3RvLlByb3RvS2V5VXBIABI5ChFjb250cm9sbGVyX2F0dGFjaBgJIAEoCzIcLnByb3RvLlByb3RvQ29udHJvbGxlckF0dGFjaEgAEjkKEWNvbnRyb2xsZXJfZGV0YWNoGAogASgLMhwucHJvdG8uUHJvdG9Db250cm9sbGVyRGV0YWNoSAASOQoRY29udHJvbGxlcl9ydW1ibGUYCyABKAsyHC5wcm90by5Qcm90b0NvbnRyb2xsZXJSdW1ibGVIABJCChZjb250cm9sbGVyX3N0YXRlX2JhdGNoGAwgASgLMiAucHJvdG8uUHJvdG9Db250cm9sbGVyU3RhdGVCYXRjaEgAEh4KA2ljZRgUIAEoCzIPLnByb3RvLlByb3RvSUNFSAASHgoDc2RwGBUgASgLMg8ucHJvdG8uUHJvdG9TRFBIABIeCgNyYXcYFiABKAsyDy5wcm90by5Qcm90b1Jhd0gAEkkKGmNsaWVudF9yZXF1ZXN0X3Jvb21fc3RyZWFtGBcgASgLMiMucHJvdG8uUHJvdG9DbGllbnRSZXF1ZXN0Um9vbVN0cmVhbUgAEj0KE2NsaWVudF9kaXNjb25uZWN0ZWQYGCABKAsyHi5wcm90by5Qcm90b0NsaWVudERpc2Nvbm5lY3RlZEgAEjoKEnNlcnZlcl9wdXNoX3N0cmVhbRgZIAEoCzIcLnByb3RvLlByb3RvU2VydmVyUHVzaFN0cmVhbUgAQgkKB3BheWxvYWRCFloUcmVsYXkvaW50ZXJuYWwvcHJvdG9iBnByb3RvMw", [file_types, file_latency_tracker]);
|
||||
|
||||
/**
|
||||
* @generated from message proto.ProtoMessageBase
|
||||
*/
|
||||
export type ProtoMessageBase = Message<"proto.ProtoMessageBase"> & {
|
||||
/**
|
||||
* @generated from field: string payload_type = 1;
|
||||
*/
|
||||
payloadType: string;
|
||||
|
||||
/**
|
||||
* @generated from field: proto.ProtoLatencyTracker latency = 2;
|
||||
*/
|
||||
latency?: ProtoLatencyTracker;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.ProtoMessageBase.
|
||||
* Use `create(ProtoMessageBaseSchema)` to create a new message.
|
||||
*/
|
||||
export const ProtoMessageBaseSchema: GenMessage<ProtoMessageBase> = /*@__PURE__*/
|
||||
messageDesc(file_messages, 0);
|
||||
|
||||
/**
|
||||
* @generated from message proto.ProtoMessage
|
||||
*/
|
||||
export type ProtoMessage = Message<"proto.ProtoMessage"> & {
|
||||
/**
|
||||
* @generated from field: proto.ProtoMessageBase message_base = 1;
|
||||
*/
|
||||
messageBase?: ProtoMessageBase;
|
||||
|
||||
/**
|
||||
* @generated from oneof proto.ProtoMessage.payload
|
||||
*/
|
||||
payload: {
|
||||
/**
|
||||
* Input types
|
||||
*
|
||||
* @generated from field: proto.ProtoMouseMove mouse_move = 2;
|
||||
*/
|
||||
value: ProtoMouseMove;
|
||||
case: "mouseMove";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: proto.ProtoMouseMoveAbs mouse_move_abs = 3;
|
||||
*/
|
||||
value: ProtoMouseMoveAbs;
|
||||
case: "mouseMoveAbs";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: proto.ProtoMouseWheel mouse_wheel = 4;
|
||||
*/
|
||||
value: ProtoMouseWheel;
|
||||
case: "mouseWheel";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: proto.ProtoMouseKeyDown mouse_key_down = 5;
|
||||
*/
|
||||
value: ProtoMouseKeyDown;
|
||||
case: "mouseKeyDown";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: proto.ProtoMouseKeyUp mouse_key_up = 6;
|
||||
*/
|
||||
value: ProtoMouseKeyUp;
|
||||
case: "mouseKeyUp";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: proto.ProtoKeyDown key_down = 7;
|
||||
*/
|
||||
value: ProtoKeyDown;
|
||||
case: "keyDown";
|
||||
} | {
|
||||
/**
|
||||
* ProtoClipboard clipboard = 9;
|
||||
*
|
||||
* @generated from field: proto.ProtoKeyUp key_up = 8;
|
||||
*/
|
||||
value: ProtoKeyUp;
|
||||
case: "keyUp";
|
||||
} | {
|
||||
/**
|
||||
* Controller input types
|
||||
*
|
||||
* @generated from field: proto.ProtoControllerAttach controller_attach = 9;
|
||||
*/
|
||||
value: ProtoControllerAttach;
|
||||
case: "controllerAttach";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: proto.ProtoControllerDetach controller_detach = 10;
|
||||
*/
|
||||
value: ProtoControllerDetach;
|
||||
case: "controllerDetach";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: proto.ProtoControllerRumble controller_rumble = 11;
|
||||
*/
|
||||
value: ProtoControllerRumble;
|
||||
case: "controllerRumble";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: proto.ProtoControllerStateBatch controller_state_batch = 12;
|
||||
*/
|
||||
value: ProtoControllerStateBatch;
|
||||
case: "controllerStateBatch";
|
||||
} | {
|
||||
/**
|
||||
* Signaling types
|
||||
*
|
||||
* @generated from field: proto.ProtoICE ice = 20;
|
||||
*/
|
||||
value: ProtoICE;
|
||||
case: "ice";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: proto.ProtoSDP sdp = 21;
|
||||
*/
|
||||
value: ProtoSDP;
|
||||
case: "sdp";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: proto.ProtoRaw raw = 22;
|
||||
*/
|
||||
value: ProtoRaw;
|
||||
case: "raw";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: proto.ProtoClientRequestRoomStream client_request_room_stream = 23;
|
||||
*/
|
||||
value: ProtoClientRequestRoomStream;
|
||||
case: "clientRequestRoomStream";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: proto.ProtoClientDisconnected client_disconnected = 24;
|
||||
*/
|
||||
value: ProtoClientDisconnected;
|
||||
case: "clientDisconnected";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: proto.ProtoServerPushStream server_push_stream = 25;
|
||||
*/
|
||||
value: ProtoServerPushStream;
|
||||
case: "serverPushStream";
|
||||
} | { case: undefined; value?: undefined };
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.ProtoMessage.
|
||||
* Use `create(ProtoMessageSchema)` to create a new message.
|
||||
*/
|
||||
export const ProtoMessageSchema: GenMessage<ProtoMessage> = /*@__PURE__*/
|
||||
messageDesc(file_messages, 1);
|
||||
|
||||
@@ -1,590 +0,0 @@
|
||||
// @generated by protoc-gen-es v2.10.1 with parameter "target=ts"
|
||||
// @generated from file types.proto (package proto, syntax proto3)
|
||||
/* eslint-disable */
|
||||
|
||||
import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
|
||||
import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
|
||||
/**
|
||||
* Describes the file types.proto.
|
||||
*/
|
||||
export const file_types: GenFile = /*@__PURE__*/
|
||||
fileDesc("Cgt0eXBlcy5wcm90bxIFcHJvdG8iJgoOUHJvdG9Nb3VzZU1vdmUSCQoBeBgBIAEoBRIJCgF5GAIgASgFIikKEVByb3RvTW91c2VNb3ZlQWJzEgkKAXgYASABKAUSCQoBeRgCIAEoBSInCg9Qcm90b01vdXNlV2hlZWwSCQoBeBgBIAEoBRIJCgF5GAIgASgFIiAKEVByb3RvTW91c2VLZXlEb3duEgsKA2tleRgBIAEoBSIeCg9Qcm90b01vdXNlS2V5VXASCwoDa2V5GAEgASgFIhsKDFByb3RvS2V5RG93bhILCgNrZXkYASABKAUiGQoKUHJvdG9LZXlVcBILCgNrZXkYASABKAUiTQoVUHJvdG9Db250cm9sbGVyQXR0YWNoEgoKAmlkGAEgASgJEhQKDHNlc3Npb25fc2xvdBgCIAEoBRISCgpzZXNzaW9uX2lkGAMgASgJIkEKFVByb3RvQ29udHJvbGxlckRldGFjaBIUCgxzZXNzaW9uX3Nsb3QYASABKAUSEgoKc2Vzc2lvbl9pZBgCIAEoCSKCAQoVUHJvdG9Db250cm9sbGVyUnVtYmxlEhQKDHNlc3Npb25fc2xvdBgBIAEoBRISCgpzZXNzaW9uX2lkGAIgASgJEhUKDWxvd19mcmVxdWVuY3kYAyABKAUSFgoOaGlnaF9mcmVxdWVuY3kYBCABKAUSEAoIZHVyYXRpb24YBSABKAUi0AUKGVByb3RvQ29udHJvbGxlclN0YXRlQmF0Y2gSFAoMc2Vzc2lvbl9zbG90GAEgASgFEhIKCnNlc3Npb25faWQYAiABKAkSQAoLdXBkYXRlX3R5cGUYAyABKA4yKy5wcm90by5Qcm90b0NvbnRyb2xsZXJTdGF0ZUJhdGNoLlVwZGF0ZVR5cGUSEAoIc2VxdWVuY2UYBCABKA0SVAoTYnV0dG9uX2NoYW5nZWRfbWFzaxgFIAMoCzI3LnByb3RvLlByb3RvQ29udHJvbGxlclN0YXRlQmF0Y2guQnV0dG9uQ2hhbmdlZE1hc2tFbnRyeRIZCgxsZWZ0X3N0aWNrX3gYBiABKAVIAIgBARIZCgxsZWZ0X3N0aWNrX3kYByABKAVIAYgBARIaCg1yaWdodF9zdGlja194GAggASgFSAKIAQESGgoNcmlnaHRfc3RpY2tfeRgJIAEoBUgDiAEBEhkKDGxlZnRfdHJpZ2dlchgKIAEoBUgEiAEBEhoKDXJpZ2h0X3RyaWdnZXIYCyABKAVIBYgBARITCgZkcGFkX3gYDCABKAVIBogBARITCgZkcGFkX3kYDSABKAVIB4gBARIbCg5jaGFuZ2VkX2ZpZWxkcxgOIAEoDUgIiAEBGjgKFkJ1dHRvbkNoYW5nZWRNYXNrRW50cnkSCwoDa2V5GAEgASgFEg0KBXZhbHVlGAIgASgIOgI4ASInCgpVcGRhdGVUeXBlEg4KCkZVTExfU1RBVEUQABIJCgVERUxUQRABQg8KDV9sZWZ0X3N0aWNrX3hCDwoNX2xlZnRfc3RpY2tfeUIQCg5fcmlnaHRfc3RpY2tfeEIQCg5fcmlnaHRfc3RpY2tfeUIPCg1fbGVmdF90cmlnZ2VyQhAKDl9yaWdodF90cmlnZ2VyQgkKB19kcGFkX3hCCQoHX2RwYWRfeUIRCg9fY2hhbmdlZF9maWVsZHMiqgEKE1JUQ0ljZUNhbmRpZGF0ZUluaXQSEQoJY2FuZGlkYXRlGAEgASgJEhoKDXNkcE1MaW5lSW5kZXgYAiABKA1IAIgBARITCgZzZHBNaWQYAyABKAlIAYgBARIdChB1c2VybmFtZUZyYWdtZW50GAQgASgJSAKIAQFCEAoOX3NkcE1MaW5lSW5kZXhCCQoHX3NkcE1pZEITChFfdXNlcm5hbWVGcmFnbWVudCI2ChlSVENTZXNzaW9uRGVzY3JpcHRpb25Jbml0EgsKA3NkcBgBIAEoCRIMCgR0eXBlGAIgASgJIjkKCFByb3RvSUNFEi0KCWNhbmRpZGF0ZRgBIAEoCzIaLnByb3RvLlJUQ0ljZUNhbmRpZGF0ZUluaXQiOQoIUHJvdG9TRFASLQoDc2RwGAEgASgLMiAucHJvdG8uUlRDU2Vzc2lvbkRlc2NyaXB0aW9uSW5pdCIYCghQcm90b1JhdxIMCgRkYXRhGAEgASgJIkUKHFByb3RvQ2xpZW50UmVxdWVzdFJvb21TdHJlYW0SEQoJcm9vbV9uYW1lGAEgASgJEhIKCnNlc3Npb25faWQYAiABKAkiRwoXUHJvdG9DbGllbnREaXNjb25uZWN0ZWQSEgoKc2Vzc2lvbl9pZBgBIAEoCRIYChBjb250cm9sbGVyX3Nsb3RzGAIgAygFIioKFVByb3RvU2VydmVyUHVzaFN0cmVhbRIRCglyb29tX25hbWUYASABKAlCFloUcmVsYXkvaW50ZXJuYWwvcHJvdG9iBnByb3RvMw");
|
||||
|
||||
/**
|
||||
* MouseMove message
|
||||
*
|
||||
* @generated from message proto.ProtoMouseMove
|
||||
*/
|
||||
export type ProtoMouseMove = Message<"proto.ProtoMouseMove"> & {
|
||||
/**
|
||||
* @generated from field: int32 x = 1;
|
||||
*/
|
||||
x: number;
|
||||
|
||||
/**
|
||||
* @generated from field: int32 y = 2;
|
||||
*/
|
||||
y: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.ProtoMouseMove.
|
||||
* Use `create(ProtoMouseMoveSchema)` to create a new message.
|
||||
*/
|
||||
export const ProtoMouseMoveSchema: GenMessage<ProtoMouseMove> = /*@__PURE__*/
|
||||
messageDesc(file_types, 0);
|
||||
|
||||
/**
|
||||
* MouseMoveAbs message
|
||||
*
|
||||
* @generated from message proto.ProtoMouseMoveAbs
|
||||
*/
|
||||
export type ProtoMouseMoveAbs = Message<"proto.ProtoMouseMoveAbs"> & {
|
||||
/**
|
||||
* @generated from field: int32 x = 1;
|
||||
*/
|
||||
x: number;
|
||||
|
||||
/**
|
||||
* @generated from field: int32 y = 2;
|
||||
*/
|
||||
y: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.ProtoMouseMoveAbs.
|
||||
* Use `create(ProtoMouseMoveAbsSchema)` to create a new message.
|
||||
*/
|
||||
export const ProtoMouseMoveAbsSchema: GenMessage<ProtoMouseMoveAbs> = /*@__PURE__*/
|
||||
messageDesc(file_types, 1);
|
||||
|
||||
/**
|
||||
* MouseWheel message
|
||||
*
|
||||
* @generated from message proto.ProtoMouseWheel
|
||||
*/
|
||||
export type ProtoMouseWheel = Message<"proto.ProtoMouseWheel"> & {
|
||||
/**
|
||||
* @generated from field: int32 x = 1;
|
||||
*/
|
||||
x: number;
|
||||
|
||||
/**
|
||||
* @generated from field: int32 y = 2;
|
||||
*/
|
||||
y: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.ProtoMouseWheel.
|
||||
* Use `create(ProtoMouseWheelSchema)` to create a new message.
|
||||
*/
|
||||
export const ProtoMouseWheelSchema: GenMessage<ProtoMouseWheel> = /*@__PURE__*/
|
||||
messageDesc(file_types, 2);
|
||||
|
||||
/**
|
||||
* MouseKeyDown message
|
||||
*
|
||||
* @generated from message proto.ProtoMouseKeyDown
|
||||
*/
|
||||
export type ProtoMouseKeyDown = Message<"proto.ProtoMouseKeyDown"> & {
|
||||
/**
|
||||
* @generated from field: int32 key = 1;
|
||||
*/
|
||||
key: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.ProtoMouseKeyDown.
|
||||
* Use `create(ProtoMouseKeyDownSchema)` to create a new message.
|
||||
*/
|
||||
export const ProtoMouseKeyDownSchema: GenMessage<ProtoMouseKeyDown> = /*@__PURE__*/
|
||||
messageDesc(file_types, 3);
|
||||
|
||||
/**
|
||||
* MouseKeyUp message
|
||||
*
|
||||
* @generated from message proto.ProtoMouseKeyUp
|
||||
*/
|
||||
export type ProtoMouseKeyUp = Message<"proto.ProtoMouseKeyUp"> & {
|
||||
/**
|
||||
* @generated from field: int32 key = 1;
|
||||
*/
|
||||
key: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.ProtoMouseKeyUp.
|
||||
* Use `create(ProtoMouseKeyUpSchema)` to create a new message.
|
||||
*/
|
||||
export const ProtoMouseKeyUpSchema: GenMessage<ProtoMouseKeyUp> = /*@__PURE__*/
|
||||
messageDesc(file_types, 4);
|
||||
|
||||
/**
|
||||
* KeyDown message
|
||||
*
|
||||
* @generated from message proto.ProtoKeyDown
|
||||
*/
|
||||
export type ProtoKeyDown = Message<"proto.ProtoKeyDown"> & {
|
||||
/**
|
||||
* @generated from field: int32 key = 1;
|
||||
*/
|
||||
key: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.ProtoKeyDown.
|
||||
* Use `create(ProtoKeyDownSchema)` to create a new message.
|
||||
*/
|
||||
export const ProtoKeyDownSchema: GenMessage<ProtoKeyDown> = /*@__PURE__*/
|
||||
messageDesc(file_types, 5);
|
||||
|
||||
/**
|
||||
* KeyUp message
|
||||
*
|
||||
* @generated from message proto.ProtoKeyUp
|
||||
*/
|
||||
export type ProtoKeyUp = Message<"proto.ProtoKeyUp"> & {
|
||||
/**
|
||||
* @generated from field: int32 key = 1;
|
||||
*/
|
||||
key: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.ProtoKeyUp.
|
||||
* Use `create(ProtoKeyUpSchema)` to create a new message.
|
||||
*/
|
||||
export const ProtoKeyUpSchema: GenMessage<ProtoKeyUp> = /*@__PURE__*/
|
||||
messageDesc(file_types, 6);
|
||||
|
||||
/**
|
||||
* ControllerAttach message
|
||||
*
|
||||
* @generated from message proto.ProtoControllerAttach
|
||||
*/
|
||||
export type ProtoControllerAttach = Message<"proto.ProtoControllerAttach"> & {
|
||||
/**
|
||||
* One of the following enums: "ps", "xbox" or "switch"
|
||||
*
|
||||
* @generated from field: string id = 1;
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Session specific slot number (0-3)
|
||||
*
|
||||
* @generated from field: int32 session_slot = 2;
|
||||
*/
|
||||
sessionSlot: number;
|
||||
|
||||
/**
|
||||
* Session ID of the client
|
||||
*
|
||||
* @generated from field: string session_id = 3;
|
||||
*/
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.ProtoControllerAttach.
|
||||
* Use `create(ProtoControllerAttachSchema)` to create a new message.
|
||||
*/
|
||||
export const ProtoControllerAttachSchema: GenMessage<ProtoControllerAttach> = /*@__PURE__*/
|
||||
messageDesc(file_types, 7);
|
||||
|
||||
/**
|
||||
* ControllerDetach message
|
||||
*
|
||||
* @generated from message proto.ProtoControllerDetach
|
||||
*/
|
||||
export type ProtoControllerDetach = Message<"proto.ProtoControllerDetach"> & {
|
||||
/**
|
||||
* Session specific slot number (0-3)
|
||||
*
|
||||
* @generated from field: int32 session_slot = 1;
|
||||
*/
|
||||
sessionSlot: number;
|
||||
|
||||
/**
|
||||
* Session ID of the client
|
||||
*
|
||||
* @generated from field: string session_id = 2;
|
||||
*/
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.ProtoControllerDetach.
|
||||
* Use `create(ProtoControllerDetachSchema)` to create a new message.
|
||||
*/
|
||||
export const ProtoControllerDetachSchema: GenMessage<ProtoControllerDetach> = /*@__PURE__*/
|
||||
messageDesc(file_types, 8);
|
||||
|
||||
/**
|
||||
* ControllerRumble message
|
||||
*
|
||||
* @generated from message proto.ProtoControllerRumble
|
||||
*/
|
||||
export type ProtoControllerRumble = Message<"proto.ProtoControllerRumble"> & {
|
||||
/**
|
||||
* Session specific slot number (0-3)
|
||||
*
|
||||
* @generated from field: int32 session_slot = 1;
|
||||
*/
|
||||
sessionSlot: number;
|
||||
|
||||
/**
|
||||
* Session ID of the client
|
||||
*
|
||||
* @generated from field: string session_id = 2;
|
||||
*/
|
||||
sessionId: string;
|
||||
|
||||
/**
|
||||
* Low frequency rumble (0-65535)
|
||||
*
|
||||
* @generated from field: int32 low_frequency = 3;
|
||||
*/
|
||||
lowFrequency: number;
|
||||
|
||||
/**
|
||||
* High frequency rumble (0-65535)
|
||||
*
|
||||
* @generated from field: int32 high_frequency = 4;
|
||||
*/
|
||||
highFrequency: number;
|
||||
|
||||
/**
|
||||
* Duration in milliseconds
|
||||
*
|
||||
* @generated from field: int32 duration = 5;
|
||||
*/
|
||||
duration: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.ProtoControllerRumble.
|
||||
* Use `create(ProtoControllerRumbleSchema)` to create a new message.
|
||||
*/
|
||||
export const ProtoControllerRumbleSchema: GenMessage<ProtoControllerRumble> = /*@__PURE__*/
|
||||
messageDesc(file_types, 9);
|
||||
|
||||
/**
|
||||
* ControllerStateBatch - single message containing full or partial controller state
|
||||
*
|
||||
* @generated from message proto.ProtoControllerStateBatch
|
||||
*/
|
||||
export type ProtoControllerStateBatch = Message<"proto.ProtoControllerStateBatch"> & {
|
||||
/**
|
||||
* Session specific slot number (0-3)
|
||||
*
|
||||
* @generated from field: int32 session_slot = 1;
|
||||
*/
|
||||
sessionSlot: number;
|
||||
|
||||
/**
|
||||
* Session ID of the client
|
||||
*
|
||||
* @generated from field: string session_id = 2;
|
||||
*/
|
||||
sessionId: string;
|
||||
|
||||
/**
|
||||
* @generated from field: proto.ProtoControllerStateBatch.UpdateType update_type = 3;
|
||||
*/
|
||||
updateType: ProtoControllerStateBatch_UpdateType;
|
||||
|
||||
/**
|
||||
* Sequence number for packet loss detection
|
||||
*
|
||||
* @generated from field: uint32 sequence = 4;
|
||||
*/
|
||||
sequence: number;
|
||||
|
||||
/**
|
||||
* Button state map (Linux event codes)
|
||||
*
|
||||
* @generated from field: map<int32, bool> button_changed_mask = 5;
|
||||
*/
|
||||
buttonChangedMask: { [key: number]: boolean };
|
||||
|
||||
/**
|
||||
* Analog inputs
|
||||
*
|
||||
* -32768 to 32767
|
||||
*
|
||||
* @generated from field: optional int32 left_stick_x = 6;
|
||||
*/
|
||||
leftStickX?: number;
|
||||
|
||||
/**
|
||||
* -32768 to 32767
|
||||
*
|
||||
* @generated from field: optional int32 left_stick_y = 7;
|
||||
*/
|
||||
leftStickY?: number;
|
||||
|
||||
/**
|
||||
* -32768 to 32767
|
||||
*
|
||||
* @generated from field: optional int32 right_stick_x = 8;
|
||||
*/
|
||||
rightStickX?: number;
|
||||
|
||||
/**
|
||||
* -32768 to 32767
|
||||
*
|
||||
* @generated from field: optional int32 right_stick_y = 9;
|
||||
*/
|
||||
rightStickY?: number;
|
||||
|
||||
/**
|
||||
* -32768 to 32767
|
||||
*
|
||||
* @generated from field: optional int32 left_trigger = 10;
|
||||
*/
|
||||
leftTrigger?: number;
|
||||
|
||||
/**
|
||||
* -32768 to 32767
|
||||
*
|
||||
* @generated from field: optional int32 right_trigger = 11;
|
||||
*/
|
||||
rightTrigger?: number;
|
||||
|
||||
/**
|
||||
* -1, 0, or 1
|
||||
*
|
||||
* @generated from field: optional int32 dpad_x = 12;
|
||||
*/
|
||||
dpadX?: number;
|
||||
|
||||
/**
|
||||
* -1, 0, or 1
|
||||
*
|
||||
* @generated from field: optional int32 dpad_y = 13;
|
||||
*/
|
||||
dpadY?: number;
|
||||
|
||||
/**
|
||||
* Bitmask indicating which fields have changed
|
||||
* Bit 0: button_changed_mask, Bit 1: left_stick_x, Bit 2: left_stick_y, etc.
|
||||
*
|
||||
* @generated from field: optional uint32 changed_fields = 14;
|
||||
*/
|
||||
changedFields?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.ProtoControllerStateBatch.
|
||||
* Use `create(ProtoControllerStateBatchSchema)` to create a new message.
|
||||
*/
|
||||
export const ProtoControllerStateBatchSchema: GenMessage<ProtoControllerStateBatch> = /*@__PURE__*/
|
||||
messageDesc(file_types, 10);
|
||||
|
||||
/**
|
||||
* @generated from enum proto.ProtoControllerStateBatch.UpdateType
|
||||
*/
|
||||
export enum ProtoControllerStateBatch_UpdateType {
|
||||
/**
|
||||
* Complete controller state
|
||||
*
|
||||
* @generated from enum value: FULL_STATE = 0;
|
||||
*/
|
||||
FULL_STATE = 0,
|
||||
|
||||
/**
|
||||
* Only changed fields
|
||||
*
|
||||
* @generated from enum value: DELTA = 1;
|
||||
*/
|
||||
DELTA = 1,
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the enum proto.ProtoControllerStateBatch.UpdateType.
|
||||
*/
|
||||
export const ProtoControllerStateBatch_UpdateTypeSchema: GenEnum<ProtoControllerStateBatch_UpdateType> = /*@__PURE__*/
|
||||
enumDesc(file_types, 10, 0);
|
||||
|
||||
/**
|
||||
* @generated from message proto.RTCIceCandidateInit
|
||||
*/
|
||||
export type RTCIceCandidateInit = Message<"proto.RTCIceCandidateInit"> & {
|
||||
/**
|
||||
* @generated from field: string candidate = 1;
|
||||
*/
|
||||
candidate: string;
|
||||
|
||||
/**
|
||||
* @generated from field: optional uint32 sdpMLineIndex = 2;
|
||||
*/
|
||||
sdpMLineIndex?: number;
|
||||
|
||||
/**
|
||||
* @generated from field: optional string sdpMid = 3;
|
||||
*/
|
||||
sdpMid?: string;
|
||||
|
||||
/**
|
||||
* @generated from field: optional string usernameFragment = 4;
|
||||
*/
|
||||
usernameFragment?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.RTCIceCandidateInit.
|
||||
* Use `create(RTCIceCandidateInitSchema)` to create a new message.
|
||||
*/
|
||||
export const RTCIceCandidateInitSchema: GenMessage<RTCIceCandidateInit> = /*@__PURE__*/
|
||||
messageDesc(file_types, 11);
|
||||
|
||||
/**
|
||||
* @generated from message proto.RTCSessionDescriptionInit
|
||||
*/
|
||||
export type RTCSessionDescriptionInit = Message<"proto.RTCSessionDescriptionInit"> & {
|
||||
/**
|
||||
* @generated from field: string sdp = 1;
|
||||
*/
|
||||
sdp: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string type = 2;
|
||||
*/
|
||||
type: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.RTCSessionDescriptionInit.
|
||||
* Use `create(RTCSessionDescriptionInitSchema)` to create a new message.
|
||||
*/
|
||||
export const RTCSessionDescriptionInitSchema: GenMessage<RTCSessionDescriptionInit> = /*@__PURE__*/
|
||||
messageDesc(file_types, 12);
|
||||
|
||||
/**
|
||||
* ProtoICE message
|
||||
*
|
||||
* @generated from message proto.ProtoICE
|
||||
*/
|
||||
export type ProtoICE = Message<"proto.ProtoICE"> & {
|
||||
/**
|
||||
* @generated from field: proto.RTCIceCandidateInit candidate = 1;
|
||||
*/
|
||||
candidate?: RTCIceCandidateInit;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.ProtoICE.
|
||||
* Use `create(ProtoICESchema)` to create a new message.
|
||||
*/
|
||||
export const ProtoICESchema: GenMessage<ProtoICE> = /*@__PURE__*/
|
||||
messageDesc(file_types, 13);
|
||||
|
||||
/**
|
||||
* ProtoSDP message
|
||||
*
|
||||
* @generated from message proto.ProtoSDP
|
||||
*/
|
||||
export type ProtoSDP = Message<"proto.ProtoSDP"> & {
|
||||
/**
|
||||
* @generated from field: proto.RTCSessionDescriptionInit sdp = 1;
|
||||
*/
|
||||
sdp?: RTCSessionDescriptionInit;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.ProtoSDP.
|
||||
* Use `create(ProtoSDPSchema)` to create a new message.
|
||||
*/
|
||||
export const ProtoSDPSchema: GenMessage<ProtoSDP> = /*@__PURE__*/
|
||||
messageDesc(file_types, 14);
|
||||
|
||||
/**
|
||||
* ProtoRaw message
|
||||
*
|
||||
* @generated from message proto.ProtoRaw
|
||||
*/
|
||||
export type ProtoRaw = Message<"proto.ProtoRaw"> & {
|
||||
/**
|
||||
* @generated from field: string data = 1;
|
||||
*/
|
||||
data: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.ProtoRaw.
|
||||
* Use `create(ProtoRawSchema)` to create a new message.
|
||||
*/
|
||||
export const ProtoRawSchema: GenMessage<ProtoRaw> = /*@__PURE__*/
|
||||
messageDesc(file_types, 15);
|
||||
|
||||
/**
|
||||
* ProtoClientRequestRoomStream message
|
||||
*
|
||||
* @generated from message proto.ProtoClientRequestRoomStream
|
||||
*/
|
||||
export type ProtoClientRequestRoomStream = Message<"proto.ProtoClientRequestRoomStream"> & {
|
||||
/**
|
||||
* @generated from field: string room_name = 1;
|
||||
*/
|
||||
roomName: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string session_id = 2;
|
||||
*/
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.ProtoClientRequestRoomStream.
|
||||
* Use `create(ProtoClientRequestRoomStreamSchema)` to create a new message.
|
||||
*/
|
||||
export const ProtoClientRequestRoomStreamSchema: GenMessage<ProtoClientRequestRoomStream> = /*@__PURE__*/
|
||||
messageDesc(file_types, 16);
|
||||
|
||||
/**
|
||||
* ProtoClientDisconnected message
|
||||
*
|
||||
* @generated from message proto.ProtoClientDisconnected
|
||||
*/
|
||||
export type ProtoClientDisconnected = Message<"proto.ProtoClientDisconnected"> & {
|
||||
/**
|
||||
* @generated from field: string session_id = 1;
|
||||
*/
|
||||
sessionId: string;
|
||||
|
||||
/**
|
||||
* @generated from field: repeated int32 controller_slots = 2;
|
||||
*/
|
||||
controllerSlots: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.ProtoClientDisconnected.
|
||||
* Use `create(ProtoClientDisconnectedSchema)` to create a new message.
|
||||
*/
|
||||
export const ProtoClientDisconnectedSchema: GenMessage<ProtoClientDisconnected> = /*@__PURE__*/
|
||||
messageDesc(file_types, 17);
|
||||
|
||||
/**
|
||||
* ProtoServerPushStream message
|
||||
*
|
||||
* @generated from message proto.ProtoServerPushStream
|
||||
*/
|
||||
export type ProtoServerPushStream = Message<"proto.ProtoServerPushStream"> & {
|
||||
/**
|
||||
* @generated from field: string room_name = 1;
|
||||
*/
|
||||
roomName: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message proto.ProtoServerPushStream.
|
||||
* Use `create(ProtoServerPushStreamSchema)` to create a new message.
|
||||
*/
|
||||
export const ProtoServerPushStreamSchema: GenMessage<ProtoServerPushStream> = /*@__PURE__*/
|
||||
messageDesc(file_types, 18);
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { pbStream, type ProtobufStream } from "@libp2p/utils";
|
||||
import type { Stream } from "@libp2p/interface";
|
||||
import { bufbuildAdapter } from "./utils";
|
||||
import {
|
||||
ProtoMessage,
|
||||
ProtoMessageSchema,
|
||||
ProtoMessageBase,
|
||||
} from "./proto/messages_pb";
|
||||
|
||||
type MessageHandler = (
|
||||
data: any,
|
||||
base: ProtoMessageBase,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export class P2PMessageStream {
|
||||
private pb: ProtobufStream;
|
||||
private handlers = new Map<string, MessageHandler[]>();
|
||||
private closed = false;
|
||||
private readLoopRunning = false;
|
||||
|
||||
constructor(stream: Stream) {
|
||||
this.pb = pbStream(stream);
|
||||
}
|
||||
|
||||
public on(payloadType: string, handler: MessageHandler): void {
|
||||
if (!this.handlers.has(payloadType)) {
|
||||
this.handlers.set(payloadType, []);
|
||||
}
|
||||
this.handlers.get(payloadType)!.push(handler);
|
||||
|
||||
if (!this.readLoopRunning) this.startReading().catch(console.error);
|
||||
}
|
||||
|
||||
private async startReading(): Promise<void> {
|
||||
if (this.readLoopRunning || this.closed) return;
|
||||
this.readLoopRunning = true;
|
||||
|
||||
while (!this.closed) {
|
||||
try {
|
||||
const msg: ProtoMessage = await this.pb.read(
|
||||
bufbuildAdapter(ProtoMessageSchema),
|
||||
);
|
||||
|
||||
const payloadType = msg.messageBase?.payloadType;
|
||||
if (payloadType && this.handlers.has(payloadType)) {
|
||||
const handlers = this.handlers.get(payloadType)!;
|
||||
if (msg.payload.value) {
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
await handler(msg.payload.value, msg.messageBase);
|
||||
} catch (err) {
|
||||
console.error(`Error in handler for ${payloadType}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.closed) break;
|
||||
console.error("Stream read error:", err);
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
this.readLoopRunning = false;
|
||||
}
|
||||
|
||||
public async write(
|
||||
message: ProtoMessage,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<void> {
|
||||
if (this.closed)
|
||||
throw new Error("Cannot write to closed stream");
|
||||
|
||||
await this.pb.write(message, bufbuildAdapter(ProtoMessageSchema), options);
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this.closed = true;
|
||||
this.handlers.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { create, toBinary, fromBinary } from "@bufbuild/protobuf";
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import { Uint8ArrayList } from "uint8arraylist";
|
||||
import type { GenMessage } from "@bufbuild/protobuf/codegenv2";
|
||||
import { timestampFromDate } from "@bufbuild/protobuf/wkt";
|
||||
import {
|
||||
ProtoLatencyTracker,
|
||||
ProtoLatencyTrackerSchema,
|
||||
ProtoTimestampEntrySchema,
|
||||
} from "./proto/latency_tracker_pb";
|
||||
import {
|
||||
ProtoMessage,
|
||||
ProtoMessageSchema,
|
||||
ProtoMessageBaseSchema,
|
||||
} from "./proto/messages_pb";
|
||||
|
||||
export function bufbuildAdapter<T extends Message>(schema: GenMessage<T>) {
|
||||
return {
|
||||
encode: (data: T): Uint8Array => {
|
||||
return toBinary(schema, data);
|
||||
},
|
||||
decode: (data: Uint8Array | Uint8ArrayList): T => {
|
||||
// Convert Uint8ArrayList to Uint8Array if needed
|
||||
const bytes = data instanceof Uint8ArrayList ? data.subarray() : data;
|
||||
return fromBinary(schema, bytes);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Latency tracker helpers
|
||||
export function createLatencyTracker(sequenceId?: string): ProtoLatencyTracker {
|
||||
return create(ProtoLatencyTrackerSchema, {
|
||||
sequenceId: sequenceId || crypto.randomUUID(),
|
||||
timestamps: [],
|
||||
});
|
||||
}
|
||||
|
||||
export function addLatencyTimestamp(
|
||||
tracker: ProtoLatencyTracker,
|
||||
stage: string,
|
||||
): ProtoLatencyTracker {
|
||||
const entry = create(ProtoTimestampEntrySchema, {
|
||||
stage,
|
||||
time: timestampFromDate(new Date()),
|
||||
});
|
||||
|
||||
return {
|
||||
...tracker,
|
||||
timestamps: [...tracker.timestamps, entry],
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateMessageOptions {
|
||||
sequenceId?: string;
|
||||
}
|
||||
|
||||
function derivePayloadCase(data: Message): string {
|
||||
// Extract case from $typeName: "proto.ProtoICE" -> "ice"
|
||||
// "proto.ProtoControllerAttach" -> "controllerAttach"
|
||||
const typeName = data.$typeName;
|
||||
if (!typeName)
|
||||
throw new Error("Message has no $typeName");
|
||||
|
||||
// Remove "proto.Proto" prefix and convert first char to lowercase
|
||||
const caseName = typeName.replace(/^proto\.Proto/, "");
|
||||
|
||||
// Convert PascalCase to camelCase
|
||||
// If it's all caps (like SDP, ICE), lowercase everything
|
||||
// Otherwise, just lowercase the first character
|
||||
if (caseName === caseName.toUpperCase()) {
|
||||
return caseName.toLowerCase();
|
||||
}
|
||||
return caseName.charAt(0).toLowerCase() + caseName.slice(1);
|
||||
}
|
||||
|
||||
export function createMessage(
|
||||
data: Message,
|
||||
payloadType: string,
|
||||
options?: CreateMessageOptions,
|
||||
): ProtoMessage {
|
||||
const payloadCase = derivePayloadCase(data);
|
||||
|
||||
return create(ProtoMessageSchema, {
|
||||
messageBase: create(ProtoMessageBaseSchema, {
|
||||
payloadType,
|
||||
latency: options?.sequenceId
|
||||
? createLatencyTracker(options.sequenceId)
|
||||
: undefined,
|
||||
}),
|
||||
payload: {
|
||||
case: payloadCase,
|
||||
value: data,
|
||||
} as any, // Type assertion needed for dynamic case
|
||||
});
|
||||
}
|
||||
@@ -1,451 +0,0 @@
|
||||
import { webSockets } from "@libp2p/websockets";
|
||||
import { webTransport } from "@libp2p/webtransport";
|
||||
import { createLibp2p, Libp2p } from "libp2p";
|
||||
import { noise } from "@chainsafe/libp2p-noise";
|
||||
import { yamux } from "@chainsafe/libp2p-yamux";
|
||||
import { identify } from "@libp2p/identify";
|
||||
import { multiaddr } from "@multiformats/multiaddr";
|
||||
import { Connection } from "@libp2p/interface";
|
||||
import { ping } from "@libp2p/ping";
|
||||
import { createMessage } from "./utils";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import {
|
||||
ProtoClientRequestRoomStream,
|
||||
ProtoClientRequestRoomStreamSchema,
|
||||
ProtoICE,
|
||||
ProtoICESchema,
|
||||
ProtoRaw,
|
||||
ProtoSDP,
|
||||
ProtoSDPSchema,
|
||||
} from "./proto/types_pb";
|
||||
import { P2PMessageStream } from "./streamwrapper";
|
||||
|
||||
const NESTRI_PROTOCOL_STREAM_REQUEST = "/nestri-relay/stream-request/1.0.0";
|
||||
|
||||
export class WebRTCStream {
|
||||
private _sessionId: string | null = null;
|
||||
private _p2p: Libp2p | undefined = undefined;
|
||||
private _p2pConn: Connection | undefined = undefined;
|
||||
private _msgStream: P2PMessageStream | undefined = undefined;
|
||||
private _pc: RTCPeerConnection | undefined = undefined;
|
||||
private _audioTrack: MediaStreamTrack | undefined = undefined;
|
||||
private _videoTrack: MediaStreamTrack | undefined = undefined;
|
||||
private _dataChannel: RTCDataChannel | undefined = undefined;
|
||||
private _onConnected: ((stream: MediaStream | null) => void) | undefined =
|
||||
undefined;
|
||||
private _connectionTimer: NodeJS.Timeout | NodeJS.Timer | undefined =
|
||||
undefined;
|
||||
private _serverURL: string | undefined = undefined;
|
||||
private _roomName: string | undefined = undefined;
|
||||
private _isConnected: boolean = false;
|
||||
private _dataChannelCallbacks: Array<(data: any) => void> = [];
|
||||
|
||||
constructor(
|
||||
serverURL: string,
|
||||
roomName: string,
|
||||
connectedCallback: (stream: MediaStream | null) => void,
|
||||
) {
|
||||
if (roomName.length <= 0) {
|
||||
console.error("Room name not provided");
|
||||
return;
|
||||
}
|
||||
|
||||
this._onConnected = connectedCallback;
|
||||
this._serverURL = serverURL;
|
||||
this._roomName = roomName;
|
||||
this._setup(serverURL, roomName).catch(console.error);
|
||||
}
|
||||
|
||||
private async _setup(serverURL: string, roomName: string) {
|
||||
// Don't setup new connection if already connected
|
||||
if (this._isConnected) {
|
||||
console.log("Already connected, skipping setup");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Setting up libp2p");
|
||||
|
||||
this._p2p = await createLibp2p({
|
||||
transports: [webSockets(), webTransport()],
|
||||
connectionEncrypters: [noise()],
|
||||
streamMuxers: [yamux()],
|
||||
connectionGater: {
|
||||
denyDialMultiaddr: () => {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
services: {
|
||||
identify: identify(),
|
||||
ping: ping(),
|
||||
},
|
||||
});
|
||||
|
||||
this._p2p.addEventListener("peer:connect", async (e) => {
|
||||
console.debug("Peer connected:", e.detail);
|
||||
});
|
||||
this._p2p.addEventListener("peer:disconnect", (e) => {
|
||||
console.debug("Peer disconnected:", e.detail);
|
||||
});
|
||||
|
||||
const ma = multiaddr(serverURL);
|
||||
console.debug("Dialing peer at:", ma.toString());
|
||||
this._p2pConn = await this._p2p.dial(ma);
|
||||
|
||||
if (this._p2pConn) {
|
||||
console.log("Stream is being established");
|
||||
let stream = await this._p2pConn
|
||||
.newStream(NESTRI_PROTOCOL_STREAM_REQUEST)
|
||||
.catch(console.error);
|
||||
if (stream) {
|
||||
this._msgStream = new P2PMessageStream(stream);
|
||||
console.log("Stream opened with peer");
|
||||
|
||||
let iceHolder: RTCIceCandidateInit[] = [];
|
||||
this._msgStream.on("ice-candidate", (data: ProtoICE) => {
|
||||
const cand: RTCIceCandidateInit = {
|
||||
candidate: data.candidate.candidate,
|
||||
sdpMLineIndex: data.candidate.sdpMLineIndex,
|
||||
sdpMid: data.candidate.sdpMid,
|
||||
usernameFragment: data.candidate.usernameFragment,
|
||||
};
|
||||
if (this._pc) {
|
||||
if (this._pc.remoteDescription) {
|
||||
this._pc.addIceCandidate(cand).catch((err) => {
|
||||
console.error("Error adding ICE candidate:", err);
|
||||
});
|
||||
// Add held candidates
|
||||
iceHolder.forEach((candidate) => {
|
||||
this._pc!.addIceCandidate(candidate).catch((err) => {
|
||||
console.error("Error adding held ICE candidate:", err);
|
||||
});
|
||||
});
|
||||
iceHolder = [];
|
||||
} else {
|
||||
iceHolder.push(cand);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this._msgStream.on(
|
||||
"session-assigned",
|
||||
(data: ProtoClientRequestRoomStream) => {
|
||||
this._sessionId = data.sessionId;
|
||||
localStorage.setItem("nestri-session-id", this._sessionId);
|
||||
console.log(
|
||||
"Session ID assigned:",
|
||||
this._sessionId,
|
||||
"for room:",
|
||||
data.roomName,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
this._msgStream.on("offer", async (data: ProtoSDP) => {
|
||||
if (!this._pc) {
|
||||
// Setup peer connection now
|
||||
this._setupPeerConnection();
|
||||
}
|
||||
await this._pc!.setRemoteDescription({
|
||||
sdp: data.sdp.sdp,
|
||||
type: data.sdp.type as RTCSdpType,
|
||||
});
|
||||
// Add held candidates
|
||||
iceHolder.forEach((candidate) => {
|
||||
this._pc!.addIceCandidate(candidate).catch((err) => {
|
||||
console.error("Error adding held ICE candidate:", err);
|
||||
});
|
||||
});
|
||||
iceHolder = [];
|
||||
|
||||
// Create our answer
|
||||
const answer = await this._pc!.createAnswer();
|
||||
// Force stereo in Chromium browsers
|
||||
answer.sdp = this.forceOpusStereo(answer.sdp!);
|
||||
await this._pc!.setLocalDescription(answer);
|
||||
// Send answer back
|
||||
const answerMsg = createMessage(
|
||||
create(ProtoSDPSchema, {
|
||||
sdp: answer,
|
||||
}),
|
||||
"answer",
|
||||
);
|
||||
await this._msgStream?.write(answerMsg);
|
||||
});
|
||||
|
||||
this._msgStream.on("request-stream-offline", (msg: ProtoRaw) => {
|
||||
console.warn("Stream is offline for room:", msg.data);
|
||||
this._onConnected?.(null);
|
||||
});
|
||||
|
||||
const clientId = this.getSessionID();
|
||||
if (clientId) {
|
||||
console.debug("Using existing session ID:", clientId);
|
||||
}
|
||||
|
||||
// Send stream request
|
||||
const requestMsg = createMessage(
|
||||
create(ProtoClientRequestRoomStreamSchema, {
|
||||
roomName: roomName,
|
||||
sessionId: clientId ?? "",
|
||||
}),
|
||||
"request-stream-room",
|
||||
);
|
||||
await this._msgStream.write(requestMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getSessionID(): string | null {
|
||||
if (this._sessionId === null)
|
||||
this._sessionId = localStorage.getItem("nestri-session-id");
|
||||
return this._sessionId;
|
||||
}
|
||||
|
||||
// Forces opus to stereo in Chromium browsers, because of course
|
||||
private forceOpusStereo(SDP: string): string {
|
||||
// Look for "minptime=10;useinbandfec=1" and replace with "minptime=10;useinbandfec=1;stereo=1;sprop-stereo=1;"
|
||||
return SDP.replace(
|
||||
/(minptime=10;useinbandfec=1)/,
|
||||
"$1;stereo=1;sprop-stereo=1;",
|
||||
);
|
||||
}
|
||||
|
||||
private _setupPeerConnection() {
|
||||
if (this._pc) {
|
||||
this._cleanupPeerConnection();
|
||||
}
|
||||
|
||||
console.log("Setting up PeerConnection");
|
||||
this._pc = new RTCPeerConnection({
|
||||
iceServers: [
|
||||
{
|
||||
urls: "stun:stun.l.google.com:19302",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this._pc.ontrack = (e) => {
|
||||
console.debug("Track received: ", e.track);
|
||||
if (e.track.kind === "audio") this._audioTrack = e.track;
|
||||
else if (e.track.kind === "video") this._videoTrack = e.track;
|
||||
|
||||
this._checkConnectionState();
|
||||
};
|
||||
|
||||
this._pc.onconnectionstatechange = () => {
|
||||
console.debug("Connection state changed to: ", this._pc!.connectionState);
|
||||
this._checkConnectionState();
|
||||
};
|
||||
|
||||
this._pc.oniceconnectionstatechange = () => {
|
||||
console.debug(
|
||||
"ICE connection state changed to: ",
|
||||
this._pc!.iceConnectionState,
|
||||
);
|
||||
this._checkConnectionState();
|
||||
};
|
||||
|
||||
this._pc.onicegatheringstatechange = () => {
|
||||
console.debug(
|
||||
"ICE gathering state changed to: ",
|
||||
this._pc!.iceGatheringState,
|
||||
);
|
||||
this._checkConnectionState();
|
||||
};
|
||||
|
||||
this._pc.onicecandidate = (e) => {
|
||||
if (e.candidate) {
|
||||
const iceMsg = createMessage(
|
||||
create(ProtoICESchema, {
|
||||
candidate: e.candidate,
|
||||
}),
|
||||
"ice-candidate",
|
||||
);
|
||||
if (this._msgStream) {
|
||||
this._msgStream
|
||||
.write(iceMsg)
|
||||
.catch((err) => console.error("Error sending ICE candidate:", err));
|
||||
} else {
|
||||
console.warn("P2P stream not established, cannot send ICE candidate");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this._pc.ondatachannel = (e) => {
|
||||
this._dataChannel = e.channel;
|
||||
this._setupDataChannelEvents();
|
||||
};
|
||||
}
|
||||
|
||||
private _checkConnectionState() {
|
||||
if (!this._pc || !this._p2p || !this._p2pConn) return;
|
||||
|
||||
console.debug("Checking connection state:", {
|
||||
connectionState: this._pc.connectionState,
|
||||
iceConnectionState: this._pc.iceConnectionState,
|
||||
hasAudioTrack: !!this._audioTrack,
|
||||
hasVideoTrack: !!this._videoTrack,
|
||||
isConnected: this._isConnected,
|
||||
});
|
||||
|
||||
if (
|
||||
this._pc.connectionState === "connected" &&
|
||||
this._audioTrack !== undefined &&
|
||||
this._videoTrack !== undefined
|
||||
) {
|
||||
this._clearConnectionTimer();
|
||||
if (!this._isConnected) {
|
||||
// Only trigger callback if not already connected
|
||||
this._isConnected = true;
|
||||
if (this._onConnected !== undefined) {
|
||||
this._onConnected(
|
||||
new MediaStream([this._audioTrack, this._videoTrack]),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
this._pc.connectionState === "failed" ||
|
||||
this._pc.connectionState === "closed" ||
|
||||
this._pc.iceConnectionState === "failed"
|
||||
) {
|
||||
console.log("PeerConnection failed or closed");
|
||||
//this._isConnected = false; // Reset connected state
|
||||
//this._handleConnectionFailure();
|
||||
}
|
||||
}
|
||||
|
||||
private _handleConnectionFailure() {
|
||||
this._clearConnectionTimer();
|
||||
if (this._isConnected) {
|
||||
// Only notify if previously connected
|
||||
this._isConnected = false;
|
||||
if (this._onConnected) {
|
||||
this._onConnected(null);
|
||||
}
|
||||
}
|
||||
this._cleanupPeerConnection();
|
||||
|
||||
// Attempt to reconnect only if not already connected
|
||||
if (!this._isConnected && this._serverURL && this._roomName) {
|
||||
this._setup(this._serverURL, this._roomName).catch((err) =>
|
||||
console.error("Reconnection failed:", err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _cleanupPeerConnection() {
|
||||
if (this._pc) {
|
||||
try {
|
||||
this._pc.close();
|
||||
} catch (err) {
|
||||
console.error("Error closing peer connection:", err);
|
||||
}
|
||||
this._pc = undefined;
|
||||
}
|
||||
|
||||
if (this._audioTrack || this._videoTrack) {
|
||||
try {
|
||||
if (this._audioTrack) this._audioTrack.stop();
|
||||
if (this._videoTrack) this._videoTrack.stop();
|
||||
} catch (err) {
|
||||
console.error("Error stopping media tracks:", err);
|
||||
}
|
||||
this._audioTrack = undefined;
|
||||
this._videoTrack = undefined;
|
||||
}
|
||||
|
||||
if (this._dataChannel) {
|
||||
try {
|
||||
this._dataChannel.close();
|
||||
} catch (err) {
|
||||
console.error("Error closing data channel:", err);
|
||||
}
|
||||
this._dataChannel = undefined;
|
||||
this._dataChannelCallbacks = [];
|
||||
}
|
||||
this._isConnected = false; // Reset connected state during cleanup
|
||||
}
|
||||
|
||||
private _clearConnectionTimer() {
|
||||
if (this._connectionTimer) {
|
||||
clearTimeout(this._connectionTimer as any);
|
||||
this._connectionTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public addDataChannelCallback(callback: (data: any) => void) {
|
||||
this._dataChannelCallbacks.push(callback);
|
||||
}
|
||||
|
||||
public removeDataChannelCallback(callback: (data: any) => void) {
|
||||
this._dataChannelCallbacks = this._dataChannelCallbacks.filter(
|
||||
(cb) => cb !== callback,
|
||||
);
|
||||
}
|
||||
|
||||
private _setupDataChannelEvents() {
|
||||
if (!this._dataChannel) return;
|
||||
|
||||
this._dataChannel.onclose = () => console.log("sendChannel has closed");
|
||||
this._dataChannel.onopen = () => console.log("sendChannel has opened");
|
||||
this._dataChannel.onmessage = (event) => {
|
||||
// Parse as ProtoBuf message
|
||||
const data = event.data;
|
||||
// Call registered callback if exists
|
||||
this._dataChannelCallbacks.forEach((callback) => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (err) {
|
||||
console.error("Error in data channel callback:", err);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private async _gatherStats(): Promise<any> {
|
||||
if (
|
||||
this._pc === undefined ||
|
||||
this._videoTrack === undefined ||
|
||||
!this._isConnected
|
||||
)
|
||||
return null;
|
||||
|
||||
return new Promise<any>((resolve) => {
|
||||
// Keep trying to get stats until gotten
|
||||
const interval = setInterval(async () => {
|
||||
if (this._pc === undefined) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = await this._pc!.getStats(this._videoTrack);
|
||||
stats.forEach((report) => {
|
||||
if (report.type === "inbound-rtp") {
|
||||
clearInterval(interval);
|
||||
|
||||
resolve({ pli: report.pliCount, nack: report.nackCount });
|
||||
}
|
||||
});
|
||||
}, 250);
|
||||
});
|
||||
}
|
||||
|
||||
// Send binary message through the data channel
|
||||
public sendBinary(data: Uint8Array) {
|
||||
if (this._dataChannel && this._dataChannel.readyState === "open")
|
||||
this._dataChannel.send(data);
|
||||
else console.log("Data channel not open or not established.");
|
||||
}
|
||||
|
||||
public disconnect() {
|
||||
this._clearConnectionTimer();
|
||||
this._cleanupPeerConnection();
|
||||
if (this._p2pConn) {
|
||||
this._p2pConn
|
||||
.close()
|
||||
.catch((err) => console.error("Error closing P2P connection:", err));
|
||||
this._p2pConn = undefined;
|
||||
}
|
||||
this._isConnected = false;
|
||||
}
|
||||
}
|
||||
9
packages/input/sst-env.d.ts
vendored
9
packages/input/sst-env.d.ts
vendored
@@ -1,9 +0,0 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -1,56 +0,0 @@
|
||||
/* eslint-env node */
|
||||
module.exports = {
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@typescript-eslint/recommended-requiring-type-checking",
|
||||
"plugin:@typescript-eslint/strict",
|
||||
"prettier",
|
||||
],
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["@typescript-eslint", "prettier", "solid"],
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2022: true,
|
||||
worker: true,
|
||||
},
|
||||
ignorePatterns: ["dist", "node_modules", ".eslintrc.cjs"],
|
||||
rules: {
|
||||
// Allow the ! operator because typescript can't always figure out when something is not undefined
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
|
||||
// Allow `any` because Javascript was not designed to be type safe.
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
|
||||
// Requring a comment in empty function is silly
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
|
||||
// Warn when an unused variable doesn't start with an underscore
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
|
||||
// The no-unsafe-* rules are a pain an introduce a lot of false-positives.
|
||||
// Typescript will make sure things are properly typed.
|
||||
"@typescript-eslint/no-unsafe-call": "off",
|
||||
"@typescript-eslint/no-unsafe-argument": "off",
|
||||
"@typescript-eslint/no-unsafe-call": "off",
|
||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-unsafe-return": "off",
|
||||
|
||||
// Make formatting errors into warnings
|
||||
"prettier/prettier": 1,
|
||||
},
|
||||
|
||||
parserOptions: {
|
||||
project: true,
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
# note: root .editorconfig is used
|
||||
|
||||
# Don't insert semi-colons unless needed
|
||||
semi: false
|
||||
@@ -1,20 +0,0 @@
|
||||
# Media over QUIC
|
||||
|
||||
Media over QUIC (MoQ) is a live media delivery protocol utilizing QUIC streams.
|
||||
See the [Warp draft](https://datatracker.ietf.org/doc/draft-lcurley-warp/).
|
||||
|
||||
This is a Typescript library that supports both contribution (ingest) and distribution (playback).
|
||||
It requires a server, such as [moq-rs](https://github.com/kixelated/moq-rs).
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
npm install @kixelated/moq
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Licensed under either:
|
||||
|
||||
- Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
|
||||
- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
|
||||
@@ -1,128 +0,0 @@
|
||||
export class Deferred<T> {
|
||||
promise: Promise<T>
|
||||
resolve!: (value: T | PromiseLike<T>) => void
|
||||
reject!: (reason: unknown) => void
|
||||
pending = true
|
||||
|
||||
constructor() {
|
||||
this.promise = new Promise((resolve, reject) => {
|
||||
this.resolve = (value) => {
|
||||
this.pending = false
|
||||
resolve(value)
|
||||
}
|
||||
this.reject = (reason) => {
|
||||
this.pending = false
|
||||
reject(reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export type WatchNext<T> = [T, Promise<WatchNext<T>> | undefined]
|
||||
|
||||
export class Watch<T> {
|
||||
#current: WatchNext<T>
|
||||
#next = new Deferred<WatchNext<T>>()
|
||||
|
||||
constructor(init: T) {
|
||||
this.#next = new Deferred<WatchNext<T>>()
|
||||
this.#current = [init, this.#next.promise]
|
||||
}
|
||||
|
||||
value(): WatchNext<T> {
|
||||
return this.#current
|
||||
}
|
||||
|
||||
update(v: T | ((v: T) => T)) {
|
||||
if (!this.#next.pending) {
|
||||
throw new Error("closed")
|
||||
}
|
||||
|
||||
// If we're given a function, call it with the current value
|
||||
let value: T
|
||||
if (v instanceof Function) {
|
||||
value = v(this.#current[0])
|
||||
} else {
|
||||
value = v
|
||||
}
|
||||
|
||||
const next = new Deferred<WatchNext<T>>()
|
||||
this.#current = [value, next.promise]
|
||||
this.#next.resolve(this.#current)
|
||||
this.#next = next
|
||||
}
|
||||
|
||||
close() {
|
||||
this.#current[1] = undefined
|
||||
this.#next.resolve(this.#current)
|
||||
}
|
||||
|
||||
closed() {
|
||||
return !this.#next.pending
|
||||
}
|
||||
}
|
||||
|
||||
// Wakes up a multiple consumers.
|
||||
export class Notify {
|
||||
#next = new Deferred<void>()
|
||||
|
||||
async wait() {
|
||||
return this.#next.promise
|
||||
}
|
||||
|
||||
wake() {
|
||||
if (!this.#next.pending) {
|
||||
throw new Error("closed")
|
||||
}
|
||||
|
||||
this.#next.resolve()
|
||||
this.#next = new Deferred<void>()
|
||||
}
|
||||
|
||||
close() {
|
||||
this.#next.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
// Allows queuing N values, like a Channel.
|
||||
export class Queue<T> {
|
||||
#stream: TransformStream<T, T>
|
||||
#closed = false
|
||||
|
||||
constructor(capacity = 1) {
|
||||
const queue = new CountQueuingStrategy({ highWaterMark: capacity })
|
||||
this.#stream = new TransformStream({}, undefined, queue)
|
||||
}
|
||||
|
||||
async push(v: T) {
|
||||
if (this.#closed) throw new Error("closed")
|
||||
const w = this.#stream.writable.getWriter()
|
||||
await w.write(v)
|
||||
w.releaseLock()
|
||||
}
|
||||
|
||||
async next(): Promise<T | undefined> {
|
||||
const r = this.#stream.readable.getReader()
|
||||
const { value, done } = await r.read()
|
||||
r.releaseLock()
|
||||
|
||||
if (done) return
|
||||
return value
|
||||
}
|
||||
|
||||
async abort(err: Error) {
|
||||
if (this.#closed) return
|
||||
await this.#stream.writable.abort(err)
|
||||
this.#closed = true
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.#closed) return
|
||||
await this.#stream.writable.close()
|
||||
this.#closed = true
|
||||
}
|
||||
|
||||
closed() {
|
||||
return this.#closed
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
// Utility function to download a Uint8Array for debugging.
|
||||
export function download(data: Uint8Array, name: string) {
|
||||
const blob = new Blob([data], {
|
||||
type: "application/octet-stream",
|
||||
})
|
||||
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = name
|
||||
document.body.appendChild(a)
|
||||
a.style.display = "none"
|
||||
a.click()
|
||||
a.remove()
|
||||
|
||||
setTimeout(() => window.URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// I hate javascript
|
||||
export function asError(e: unknown): Error {
|
||||
if (e instanceof Error) {
|
||||
return e
|
||||
}
|
||||
if (typeof e === "string") {
|
||||
return new Error(e)
|
||||
}
|
||||
return new Error(String(e))
|
||||
}
|
||||
|
||||
export function isError(e: unknown): e is Error {
|
||||
return e instanceof Error
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export function decode(str: string): Uint8Array {
|
||||
const bytes = new Uint8Array(str.length / 2)
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
bytes[i] = Number.parseInt(str.slice(2 * i, 2 * i + 2), 16)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
export function encode(_bytes: Uint8Array): string {
|
||||
throw "todo"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { asError } from "./error"
|
||||
@@ -1,172 +0,0 @@
|
||||
// Ring buffer with audio samples.
|
||||
|
||||
enum STATE {
|
||||
READ_POS = 0, // The current read position
|
||||
WRITE_POS = 1, // The current write position
|
||||
LENGTH = 2, // Clever way of saving the total number of enums values.
|
||||
}
|
||||
|
||||
interface FrameCopyToOptions {
|
||||
frameCount?: number
|
||||
frameOffset?: number
|
||||
planeIndex: number
|
||||
}
|
||||
|
||||
// This is implemented by AudioData in WebCodecs, but we don't import it because it's a DOM type.
|
||||
interface Frame {
|
||||
numberOfFrames: number
|
||||
numberOfChannels: number
|
||||
copyTo(dst: Float32Array, options: FrameCopyToOptions): void
|
||||
}
|
||||
|
||||
// No prototype to make this easier to send via postMessage
|
||||
export class RingShared {
|
||||
state: SharedArrayBuffer
|
||||
|
||||
channels: SharedArrayBuffer[]
|
||||
capacity: number
|
||||
|
||||
constructor(channels: number, capacity: number) {
|
||||
// Store the current state in a separate ring buffer.
|
||||
this.state = new SharedArrayBuffer(STATE.LENGTH * Int32Array.BYTES_PER_ELEMENT)
|
||||
|
||||
// Create a buffer for each audio channel
|
||||
this.channels = []
|
||||
for (let i = 0; i < channels; i += 1) {
|
||||
const buffer = new SharedArrayBuffer(capacity * Float32Array.BYTES_PER_ELEMENT)
|
||||
this.channels.push(buffer)
|
||||
}
|
||||
|
||||
this.capacity = capacity
|
||||
}
|
||||
}
|
||||
|
||||
export class Ring {
|
||||
state: Int32Array
|
||||
channels: Float32Array[]
|
||||
capacity: number
|
||||
|
||||
constructor(shared: RingShared) {
|
||||
this.state = new Int32Array(shared.state)
|
||||
|
||||
this.channels = []
|
||||
for (const channel of shared.channels) {
|
||||
this.channels.push(new Float32Array(channel))
|
||||
}
|
||||
|
||||
this.capacity = shared.capacity
|
||||
}
|
||||
|
||||
// Write samples for single audio frame, returning the total number written.
|
||||
write(frame: Frame): number {
|
||||
const readPos = Atomics.load(this.state, STATE.READ_POS)
|
||||
const writePos = Atomics.load(this.state, STATE.WRITE_POS)
|
||||
|
||||
const available = this.capacity - (writePos - readPos)
|
||||
if (available <= 0) return 0
|
||||
|
||||
const toWrite = Math.min(frame.numberOfFrames, available)
|
||||
const startPos = writePos
|
||||
const endPos = writePos + toWrite
|
||||
|
||||
const startIndex = startPos % this.capacity
|
||||
const endIndex = endPos % this.capacity
|
||||
|
||||
// Loop over each channel
|
||||
for (let i = 0; i < this.channels.length; i += 1) {
|
||||
const channel = this.channels[i]
|
||||
|
||||
// If the AudioData doesn't have enough channels, duplicate it.
|
||||
const planeIndex = Math.min(i, frame.numberOfChannels - 1)
|
||||
|
||||
if (startIndex < endIndex) {
|
||||
// One continuous range to copy.
|
||||
const full = channel.subarray(startIndex, endIndex)
|
||||
|
||||
frame.copyTo(full, {
|
||||
planeIndex,
|
||||
frameCount: endIndex - startIndex,
|
||||
})
|
||||
} else {
|
||||
const first = channel.subarray(startIndex)
|
||||
const second = channel.subarray(0, endIndex)
|
||||
|
||||
frame.copyTo(first, {
|
||||
planeIndex,
|
||||
frameCount: first.length,
|
||||
})
|
||||
|
||||
// We need this conditional when startIndex == 0 and endIndex == 0
|
||||
// When capacity=4410 and frameCount=1024, this was happening 52s into the audio.
|
||||
if (second.length) {
|
||||
frame.copyTo(second, {
|
||||
planeIndex,
|
||||
frameOffset: first.length,
|
||||
frameCount: second.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Atomics.store(this.state, STATE.WRITE_POS, endPos)
|
||||
|
||||
return toWrite
|
||||
}
|
||||
|
||||
read(dst: Float32Array[]): number {
|
||||
const readPos = Atomics.load(this.state, STATE.READ_POS)
|
||||
const writePos = Atomics.load(this.state, STATE.WRITE_POS)
|
||||
|
||||
const startPos = readPos
|
||||
let endPos = startPos + dst[0].length
|
||||
|
||||
if (endPos > writePos) {
|
||||
endPos = writePos
|
||||
if (endPos <= startPos) {
|
||||
// Nothing to read
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
const startIndex = startPos % this.capacity
|
||||
const endIndex = endPos % this.capacity
|
||||
|
||||
// Loop over each channel
|
||||
for (let i = 0; i < dst.length; i += 1) {
|
||||
if (i >= this.channels.length) {
|
||||
// ignore excess channels
|
||||
}
|
||||
|
||||
const input = this.channels[i]
|
||||
const output = dst[i]
|
||||
|
||||
if (startIndex < endIndex) {
|
||||
const full = input.subarray(startIndex, endIndex)
|
||||
output.set(full)
|
||||
} else {
|
||||
const first = input.subarray(startIndex)
|
||||
const second = input.subarray(0, endIndex)
|
||||
|
||||
output.set(first)
|
||||
output.set(second, first.length)
|
||||
}
|
||||
}
|
||||
|
||||
Atomics.store(this.state, STATE.READ_POS, endPos)
|
||||
|
||||
return endPos - startPos
|
||||
}
|
||||
|
||||
clear() {
|
||||
const pos = Atomics.load(this.state, STATE.WRITE_POS)
|
||||
Atomics.store(this.state, STATE.READ_POS, pos)
|
||||
}
|
||||
|
||||
size() {
|
||||
// TODO is this thread safe?
|
||||
const readPos = Atomics.load(this.state, STATE.READ_POS)
|
||||
const writePos = Atomics.load(this.state, STATE.WRITE_POS)
|
||||
|
||||
return writePos - readPos
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// MediaTrackSettings can represent both audio and video, which means a LOT of possibly undefined properties.
|
||||
// This is a fork of the MediaTrackSettings interface with properties required for audio or vidfeo.
|
||||
export interface AudioTrackSettings {
|
||||
deviceId: string
|
||||
groupId: string
|
||||
|
||||
autoGainControl: boolean
|
||||
channelCount: number
|
||||
echoCancellation: boolean
|
||||
noiseSuppression: boolean
|
||||
sampleRate: number
|
||||
sampleSize: number
|
||||
}
|
||||
|
||||
export interface VideoTrackSettings {
|
||||
deviceId: string
|
||||
groupId: string
|
||||
|
||||
aspectRatio: number
|
||||
facingMode: "user" | "environment" | "left" | "right"
|
||||
frameRate: number
|
||||
height: number
|
||||
resizeMode: "none" | "crop-and-scale"
|
||||
width: number
|
||||
}
|
||||
|
||||
export function isAudioTrackSettings(settings: MediaTrackSettings): settings is AudioTrackSettings {
|
||||
return "sampleRate" in settings
|
||||
}
|
||||
|
||||
export function isVideoTrackSettings(settings: MediaTrackSettings): settings is VideoTrackSettings {
|
||||
return "width" in settings
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["."]
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import { Deferred } from "../common/async"
|
||||
import type { Frame } from "../karp/frame"
|
||||
import type { Group, Track } from "../transfork"
|
||||
import { Closed } from "../transfork/error"
|
||||
|
||||
const SUPPORTED = [
|
||||
// TODO support AAC
|
||||
// "mp4a"
|
||||
"Opus",
|
||||
]
|
||||
|
||||
export class Packer {
|
||||
#source: MediaStreamTrackProcessor<AudioData>
|
||||
#encoder: Encoder
|
||||
|
||||
#data: Track
|
||||
#current?: Group
|
||||
|
||||
constructor(track: MediaStreamAudioTrack, encoder: Encoder, data: Track) {
|
||||
this.#source = new MediaStreamTrackProcessor({ track })
|
||||
this.#encoder = encoder
|
||||
this.#data = data
|
||||
}
|
||||
|
||||
async run() {
|
||||
const output = new WritableStream({
|
||||
write: (chunk) => this.#write(chunk),
|
||||
close: () => this.#close(),
|
||||
abort: (e) => this.#close(e),
|
||||
})
|
||||
|
||||
return this.#source.readable.pipeThrough(this.#encoder.frames).pipeTo(output)
|
||||
}
|
||||
|
||||
#write(frame: Frame) {
|
||||
// TODO use a fixed interval instead of keyframes (audio)
|
||||
// TODO actually just align with video
|
||||
if (!this.#current || frame.type === "key") {
|
||||
if (this.#current) {
|
||||
this.#current.close()
|
||||
}
|
||||
|
||||
this.#current = this.#data.appendGroup()
|
||||
}
|
||||
|
||||
this.#current.writeFrame(frame.data)
|
||||
}
|
||||
|
||||
#close(err?: unknown) {
|
||||
const closed = Closed.from(err)
|
||||
if (this.#current) {
|
||||
this.#current.close(closed)
|
||||
}
|
||||
|
||||
this.#data.close(closed)
|
||||
}
|
||||
}
|
||||
|
||||
export class Encoder {
|
||||
#encoder!: AudioEncoder
|
||||
#encoderConfig: AudioEncoderConfig
|
||||
#decoderConfig = new Deferred<AudioDecoderConfig>()
|
||||
|
||||
frames: TransformStream<AudioData, EncodedAudioChunk>
|
||||
|
||||
constructor(config: AudioEncoderConfig) {
|
||||
this.#encoderConfig = config
|
||||
|
||||
this.frames = new TransformStream({
|
||||
start: this.#start.bind(this),
|
||||
transform: this.#transform.bind(this),
|
||||
flush: this.#flush.bind(this),
|
||||
})
|
||||
}
|
||||
|
||||
#start(controller: TransformStreamDefaultController<EncodedAudioChunk>) {
|
||||
this.#encoder = new AudioEncoder({
|
||||
output: (frame, metadata) => {
|
||||
this.#enqueue(controller, frame, metadata)
|
||||
},
|
||||
error: (err) => {
|
||||
throw err
|
||||
},
|
||||
})
|
||||
|
||||
this.#encoder.configure(this.#encoderConfig)
|
||||
}
|
||||
|
||||
#transform(frame: AudioData) {
|
||||
this.#encoder.encode(frame)
|
||||
frame.close()
|
||||
}
|
||||
|
||||
#enqueue(
|
||||
controller: TransformStreamDefaultController<EncodedAudioChunk>,
|
||||
frame: EncodedAudioChunk,
|
||||
metadata?: EncodedAudioChunkMetadata,
|
||||
) {
|
||||
const config = metadata?.decoderConfig
|
||||
if (config && !this.#decoderConfig.pending) {
|
||||
const config = metadata.decoderConfig
|
||||
if (!config) throw new Error("missing decoder config")
|
||||
|
||||
this.#decoderConfig.resolve(config)
|
||||
}
|
||||
|
||||
controller.enqueue(frame)
|
||||
}
|
||||
|
||||
#flush() {
|
||||
this.#encoder.close()
|
||||
}
|
||||
|
||||
static async isSupported(config: AudioEncoderConfig) {
|
||||
// Check if we support a specific codec family
|
||||
const short = config.codec.substring(0, 4)
|
||||
if (!SUPPORTED.includes(short)) return false
|
||||
|
||||
const res = await AudioEncoder.isConfigSupported(config)
|
||||
return !!res.supported
|
||||
}
|
||||
|
||||
get config() {
|
||||
return this.#encoderConfig
|
||||
}
|
||||
|
||||
async decoderConfig(): Promise<AudioDecoderConfig> {
|
||||
return await this.#decoderConfig.promise
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import * as Catalog from "../karp/catalog"
|
||||
import * as Transfork from "../transfork"
|
||||
import * as Audio from "./audio"
|
||||
import * as Video from "./video"
|
||||
|
||||
import { isAudioTrackSettings, isVideoTrackSettings } from "../common/settings"
|
||||
|
||||
export interface BroadcastConfig {
|
||||
path: string[]
|
||||
media: MediaStream
|
||||
id?: number
|
||||
|
||||
audio?: AudioEncoderConfig
|
||||
video?: VideoEncoderConfig
|
||||
}
|
||||
|
||||
export interface BroadcastConfigTrack {
|
||||
codec: string
|
||||
bitrate: number
|
||||
}
|
||||
|
||||
export class Broadcast {
|
||||
#config: BroadcastConfig
|
||||
#path: string[]
|
||||
|
||||
constructor(config: BroadcastConfig) {
|
||||
const id = config.id || new Date().getTime() / 1000
|
||||
|
||||
this.#config = config
|
||||
this.#path = config.path.concat(id.toString())
|
||||
}
|
||||
|
||||
async publish(connection: Transfork.Connection) {
|
||||
const broadcast: Catalog.Broadcast = {
|
||||
path: this.#config.path,
|
||||
audio: [],
|
||||
video: [],
|
||||
}
|
||||
|
||||
for (const media of this.#config.media.getTracks()) {
|
||||
const settings = media.getSettings()
|
||||
|
||||
const info = {
|
||||
name: media.id, // TODO way too verbose
|
||||
priority: media.kind === "video" ? 1 : 2,
|
||||
}
|
||||
|
||||
const track = new Transfork.Track(this.#config.path.concat(info.name), info.priority)
|
||||
|
||||
if (isVideoTrackSettings(settings)) {
|
||||
if (!this.#config.video) {
|
||||
throw new Error("no video configuration provided")
|
||||
}
|
||||
|
||||
const encoder = new Video.Encoder(this.#config.video)
|
||||
const packer = new Video.Packer(media as MediaStreamVideoTrack, encoder, track)
|
||||
|
||||
// TODO handle error
|
||||
packer.run().catch((err) => console.error("failed to run video packer: ", err))
|
||||
|
||||
const decoder = await encoder.decoderConfig()
|
||||
const description = decoder.description ? new Uint8Array(decoder.description as ArrayBuffer) : undefined
|
||||
|
||||
const video: Catalog.Video = {
|
||||
track: info,
|
||||
codec: decoder.codec,
|
||||
description: description,
|
||||
resolution: { width: settings.width, height: settings.height },
|
||||
frame_rate: settings.frameRate,
|
||||
bitrate: this.#config.video.bitrate,
|
||||
}
|
||||
|
||||
broadcast.video.push(video)
|
||||
} else if (isAudioTrackSettings(settings)) {
|
||||
if (!this.#config.audio) {
|
||||
throw new Error("no audio configuration provided")
|
||||
}
|
||||
|
||||
const encoder = new Audio.Encoder(this.#config.audio)
|
||||
const packer = new Audio.Packer(media as MediaStreamAudioTrack, encoder, track)
|
||||
packer.run().catch((err) => console.error("failed to run audio packer: ", err)) // TODO handle error
|
||||
|
||||
const decoder = await encoder.decoderConfig()
|
||||
|
||||
const audio: Catalog.Audio = {
|
||||
track: info,
|
||||
codec: decoder.codec,
|
||||
sample_rate: settings.sampleRate,
|
||||
channel_count: settings.channelCount,
|
||||
bitrate: this.#config.audio.bitrate,
|
||||
}
|
||||
|
||||
broadcast.audio.push(audio)
|
||||
} else {
|
||||
throw new Error(`unknown track type: ${media.kind}`)
|
||||
}
|
||||
|
||||
connection.publish(track.reader())
|
||||
}
|
||||
|
||||
const track = new Transfork.Track(this.#config.path.concat("catalog.json"), 0)
|
||||
track.appendGroup().writeFrames(Catalog.encode(broadcast))
|
||||
|
||||
connection.publish(track.reader())
|
||||
}
|
||||
|
||||
close() {}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { Broadcast } from "./broadcast"
|
||||
export type { BroadcastConfig, BroadcastConfigTrack } from "./broadcast"
|
||||
|
||||
export { Encoder as VideoEncoder } from "./video"
|
||||
export { Encoder as AudioEncoder } from "./audio"
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { Frame } from "../karp/frame"
|
||||
|
||||
export class Segment {
|
||||
id: number
|
||||
|
||||
// Take in a stream of frames
|
||||
input: WritableStream<Frame>
|
||||
|
||||
// Output a stream of bytes, which we fork for each new subscriber.
|
||||
#cache: ReadableStream<Uint8Array>
|
||||
|
||||
timestamp = 0
|
||||
|
||||
constructor(id: number) {
|
||||
this.id = id
|
||||
|
||||
// Set a max size for each segment, dropping the tail if it gets too long.
|
||||
// We tee the reader, so this limit applies to the FASTEST reader.
|
||||
const backpressure = new ByteLengthQueuingStrategy({
|
||||
highWaterMark: 8_000_000,
|
||||
})
|
||||
|
||||
const transport = new TransformStream<Frame, Uint8Array>(
|
||||
{
|
||||
transform: (frame: Frame, controller) => {
|
||||
// Compute the max timestamp of the segment
|
||||
this.timestamp = Math.max(this.timestamp, frame.timestamp)
|
||||
|
||||
// Push the chunk to any listeners.
|
||||
controller.enqueue(frame.data)
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
backpressure,
|
||||
)
|
||||
|
||||
this.input = transport.writable
|
||||
this.#cache = transport.readable
|
||||
}
|
||||
|
||||
// Split the output reader into two parts.
|
||||
chunks(): ReadableStream<Uint8Array> {
|
||||
const [tee, cache] = this.#cache.tee()
|
||||
this.#cache = cache
|
||||
return tee
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { Notify } from "../common/async"
|
||||
import type { BroadcastConfig } from "./broadcast"
|
||||
import { Segment } from "./segment"
|
||||
|
||||
import type { Frame } from "../karp/frame"
|
||||
import * as Audio from "./audio"
|
||||
import * as Video from "./video"
|
||||
|
||||
export class Track {
|
||||
name: string
|
||||
|
||||
#init?: Uint8Array
|
||||
#segments: Segment[] = []
|
||||
|
||||
#offset = 0 // number of segments removed from the front of the queue
|
||||
#closed = false
|
||||
#error?: Error
|
||||
#notify = new Notify()
|
||||
|
||||
constructor(media: MediaStreamTrack, config: BroadcastConfig) {
|
||||
this.name = media.kind
|
||||
|
||||
// We need to split based on type because Typescript is hard
|
||||
if (isAudioTrack(media)) {
|
||||
if (!config.audio) throw new Error("no audio config")
|
||||
this.#runAudio(media, config.audio).catch((err) => this.#close(err))
|
||||
} else if (isVideoTrack(media)) {
|
||||
if (!config.video) throw new Error("no video config")
|
||||
this.#runVideo(media, config.video).catch((err) => this.#close(err))
|
||||
} else {
|
||||
throw new Error(`unknown track type: ${media.kind}`)
|
||||
}
|
||||
}
|
||||
|
||||
async #runAudio(track: MediaStreamAudioTrack, config: AudioEncoderConfig) {
|
||||
const source = new MediaStreamTrackProcessor({ track })
|
||||
const encoder = new Audio.Encoder(config)
|
||||
|
||||
// Split the container at keyframe boundaries
|
||||
const segments = new WritableStream({
|
||||
write: (chunk) => this.#write(chunk),
|
||||
close: () => this.#close(),
|
||||
abort: (e) => this.#close(e),
|
||||
})
|
||||
|
||||
return source.readable.pipeThrough(encoder.frames).pipeTo(segments)
|
||||
}
|
||||
|
||||
async #runVideo(track: MediaStreamVideoTrack, config: VideoEncoderConfig) {
|
||||
const source = new MediaStreamTrackProcessor({ track })
|
||||
const encoder = new Video.Encoder(config)
|
||||
|
||||
// Split the container at keyframe boundaries
|
||||
const segments = new WritableStream({
|
||||
write: (chunk) => this.#write(chunk),
|
||||
close: () => this.#close(),
|
||||
abort: (e) => this.#close(e),
|
||||
})
|
||||
|
||||
return source.readable.pipeThrough(encoder.frames).pipeTo(segments)
|
||||
}
|
||||
|
||||
async #write(frame: Frame) {
|
||||
let current = this.#segments.at(-1)
|
||||
if (!current || frame.type === "key") {
|
||||
if (current) {
|
||||
await current.input.close()
|
||||
}
|
||||
|
||||
const segment = new Segment(this.#offset + this.#segments.length)
|
||||
this.#segments.push(segment)
|
||||
|
||||
this.#notify.wake()
|
||||
|
||||
current = segment
|
||||
|
||||
// Clear old segments
|
||||
while (this.#segments.length > 1) {
|
||||
const first = this.#segments[0]
|
||||
|
||||
// Expire after 10s
|
||||
if (frame.timestamp - first.timestamp < 10_000_000) break
|
||||
this.#segments.shift()
|
||||
this.#offset += 1
|
||||
|
||||
await first.input.abort("expired")
|
||||
}
|
||||
}
|
||||
|
||||
const writer = current.input.getWriter()
|
||||
|
||||
if ((writer.desiredSize || 0) > 0) {
|
||||
await writer.write(frame)
|
||||
} else {
|
||||
console.warn("dropping chunk", writer.desiredSize)
|
||||
}
|
||||
|
||||
writer.releaseLock()
|
||||
}
|
||||
|
||||
async #close(e?: Error) {
|
||||
this.#error = e
|
||||
|
||||
const current = this.#segments.at(-1)
|
||||
if (current) {
|
||||
await current.input.close()
|
||||
}
|
||||
|
||||
this.#closed = true
|
||||
this.#notify.wake()
|
||||
}
|
||||
|
||||
async init(): Promise<Uint8Array> {
|
||||
while (!this.#init) {
|
||||
if (this.#closed) throw new Error("track closed")
|
||||
await this.#notify.wait()
|
||||
}
|
||||
|
||||
return this.#init
|
||||
}
|
||||
|
||||
// TODO generize this
|
||||
segments(): ReadableStream<Segment> {
|
||||
let pos = this.#offset
|
||||
|
||||
return new ReadableStream({
|
||||
pull: async (controller) => {
|
||||
for (;;) {
|
||||
let index = pos - this.#offset
|
||||
if (index < 0) index = 0
|
||||
|
||||
if (index < this.#segments.length) {
|
||||
controller.enqueue(this.#segments[index])
|
||||
pos += 1
|
||||
return // Called again when more data is requested
|
||||
}
|
||||
|
||||
if (this.#error) {
|
||||
controller.error(this.#error)
|
||||
return
|
||||
}
|
||||
if (this.#closed) {
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
|
||||
// Pull again on wakeup
|
||||
// NOTE: We can't return until we enqueue at least one segment.
|
||||
await this.#notify.wait()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function isAudioTrack(track: MediaStreamTrack): track is MediaStreamAudioTrack {
|
||||
return track.kind === "audio"
|
||||
}
|
||||
|
||||
function isVideoTrack(track: MediaStreamTrack): track is MediaStreamVideoTrack {
|
||||
return track.kind === "video"
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["."],
|
||||
"compilerOptions": {
|
||||
"types": ["dom-mediacapture-transform", "dom-webcodecs"]
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../common"
|
||||
},
|
||||
{
|
||||
"path": "../transfork"
|
||||
},
|
||||
{
|
||||
"path": "../karp"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { Deferred } from "../common/async"
|
||||
import type { Frame } from "../karp/frame"
|
||||
import type { Group, Track } from "../transfork"
|
||||
import { Closed } from "../transfork/error"
|
||||
|
||||
const SUPPORTED = [
|
||||
"avc1", // H.264
|
||||
"hev1", // HEVC (aka h.265)
|
||||
// "av01", // TDOO support AV1
|
||||
]
|
||||
|
||||
export interface EncoderSupported {
|
||||
codecs: string[]
|
||||
}
|
||||
|
||||
export class Packer {
|
||||
#source: MediaStreamTrackProcessor<VideoFrame>
|
||||
#encoder: Encoder
|
||||
|
||||
#data: Track
|
||||
#current?: Group
|
||||
|
||||
constructor(track: MediaStreamVideoTrack, encoder: Encoder, data: Track) {
|
||||
this.#source = new MediaStreamTrackProcessor({ track })
|
||||
this.#encoder = encoder
|
||||
this.#data = data
|
||||
}
|
||||
|
||||
async run() {
|
||||
const output = new WritableStream({
|
||||
write: (chunk) => this.#write(chunk),
|
||||
close: () => this.#close(),
|
||||
abort: (e) => this.#close(e),
|
||||
})
|
||||
|
||||
return this.#source.readable.pipeThrough(this.#encoder.frames).pipeTo(output)
|
||||
}
|
||||
|
||||
#write(frame: Frame) {
|
||||
if (!this.#current || frame.type === "key") {
|
||||
if (this.#current) {
|
||||
this.#current.close()
|
||||
}
|
||||
|
||||
this.#current = this.#data.appendGroup()
|
||||
}
|
||||
|
||||
frame.encode(this.#current)
|
||||
}
|
||||
|
||||
#close(err?: unknown) {
|
||||
const closed = Closed.from(err)
|
||||
if (this.#current) {
|
||||
this.#current.close(closed)
|
||||
}
|
||||
|
||||
this.#data.close(closed)
|
||||
}
|
||||
}
|
||||
|
||||
export class Encoder {
|
||||
#encoder!: VideoEncoder
|
||||
#encoderConfig: VideoEncoderConfig
|
||||
#decoderConfig = new Deferred<VideoDecoderConfig>()
|
||||
|
||||
// true if we should insert a keyframe, undefined when the encoder should decide
|
||||
#keyframeNext: true | undefined = true
|
||||
|
||||
// Count the number of frames without a keyframe.
|
||||
#keyframeCounter = 0
|
||||
|
||||
// Converts raw rames to encoded frames.
|
||||
frames: TransformStream<VideoFrame, EncodedVideoChunk>
|
||||
|
||||
constructor(config: VideoEncoderConfig) {
|
||||
config.bitrateMode ??= "constant"
|
||||
config.latencyMode ??= "realtime"
|
||||
|
||||
this.#encoderConfig = config
|
||||
|
||||
this.frames = new TransformStream({
|
||||
start: this.#start.bind(this),
|
||||
transform: this.#transform.bind(this),
|
||||
flush: this.#flush.bind(this),
|
||||
})
|
||||
}
|
||||
|
||||
static async isSupported(config: VideoEncoderConfig) {
|
||||
// Check if we support a specific codec family
|
||||
const short = config.codec.substring(0, 4)
|
||||
if (!SUPPORTED.includes(short)) return false
|
||||
|
||||
// Default to hardware encoding
|
||||
config.hardwareAcceleration ??= "prefer-hardware"
|
||||
|
||||
// Default to CBR
|
||||
config.bitrateMode ??= "constant"
|
||||
|
||||
// Default to realtime encoding
|
||||
config.latencyMode ??= "realtime"
|
||||
|
||||
const res = await VideoEncoder.isConfigSupported(config)
|
||||
return !!res.supported
|
||||
}
|
||||
|
||||
async decoderConfig(): Promise<VideoDecoderConfig> {
|
||||
return await this.#decoderConfig.promise
|
||||
}
|
||||
|
||||
#start(controller: TransformStreamDefaultController<EncodedVideoChunk>) {
|
||||
this.#encoder = new VideoEncoder({
|
||||
output: (frame, metadata) => {
|
||||
this.#enqueue(controller, frame, metadata)
|
||||
},
|
||||
error: (err) => {
|
||||
this.#decoderConfig.reject(err)
|
||||
throw err
|
||||
},
|
||||
})
|
||||
|
||||
this.#encoder.configure(this.#encoderConfig)
|
||||
}
|
||||
|
||||
#transform(frame: VideoFrame) {
|
||||
const encoder = this.#encoder
|
||||
|
||||
// Set keyFrame to undefined when we're not sure so the encoder can decide.
|
||||
encoder.encode(frame, { keyFrame: this.#keyframeNext })
|
||||
this.#keyframeNext = undefined
|
||||
|
||||
frame.close()
|
||||
}
|
||||
|
||||
#enqueue(
|
||||
controller: TransformStreamDefaultController<EncodedVideoChunk>,
|
||||
frame: EncodedVideoChunk,
|
||||
metadata?: EncodedVideoChunkMetadata,
|
||||
) {
|
||||
if (this.#decoderConfig.pending) {
|
||||
const config = metadata?.decoderConfig
|
||||
if (!config) throw new Error("missing decoder config")
|
||||
this.#decoderConfig.resolve(config)
|
||||
}
|
||||
|
||||
if (frame.type === "key") {
|
||||
this.#keyframeCounter = 0
|
||||
} else {
|
||||
this.#keyframeCounter += 1
|
||||
const framesPerGop = this.#encoderConfig.framerate ? 2 * this.#encoderConfig.framerate : 60
|
||||
if (this.#keyframeCounter + this.#encoder.encodeQueueSize >= framesPerGop) {
|
||||
this.#keyframeNext = true
|
||||
}
|
||||
}
|
||||
|
||||
controller.enqueue(frame)
|
||||
}
|
||||
|
||||
#flush() {
|
||||
this.#encoder.close()
|
||||
}
|
||||
|
||||
get config() {
|
||||
return this.#encoderConfig
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { type Track, decodeTrack } from "./track"
|
||||
|
||||
export interface Audio {
|
||||
track: Track
|
||||
codec: string
|
||||
sample_rate: number
|
||||
channel_count: number
|
||||
bitrate?: number
|
||||
}
|
||||
|
||||
export function decodeAudio(o: unknown): o is Audio {
|
||||
if (typeof o !== "object" || o === null) return false
|
||||
|
||||
const obj = o as Partial<Audio>
|
||||
if (!decodeTrack(obj.track)) return false
|
||||
if (typeof obj.codec !== "string") return false
|
||||
if (typeof obj.sample_rate !== "number") return false
|
||||
if (typeof obj.channel_count !== "number") return false
|
||||
return true
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import * as Transfork from "../../transfork"
|
||||
import { type Audio, decodeAudio } from "./audio"
|
||||
import { type Video, decodeVideo } from "./video"
|
||||
|
||||
export interface Broadcast {
|
||||
path: string[]
|
||||
video: Video[]
|
||||
audio: Audio[]
|
||||
}
|
||||
|
||||
export function encode(catalog: Broadcast): Uint8Array {
|
||||
const encoder = new TextEncoder()
|
||||
console.debug("encoding catalog", catalog)
|
||||
const str = JSON.stringify(catalog)
|
||||
return encoder.encode(str)
|
||||
}
|
||||
|
||||
export function decode(path: string[], raw: Uint8Array): Broadcast {
|
||||
const decoder = new TextDecoder()
|
||||
const str = decoder.decode(raw)
|
||||
|
||||
const catalog = JSON.parse(str)
|
||||
if (!decodeBroadcast(catalog)) {
|
||||
console.error("invalid catalog", catalog)
|
||||
throw new Error("invalid catalog")
|
||||
}
|
||||
|
||||
catalog.path = path
|
||||
return catalog
|
||||
}
|
||||
|
||||
export async function fetch(connection: Transfork.Connection, path: string[]): Promise<Broadcast> {
|
||||
const track = new Transfork.Track(path.concat("catalog.json"), 0)
|
||||
const sub = await connection.subscribe(track)
|
||||
try {
|
||||
const segment = await sub.nextGroup()
|
||||
if (!segment) throw new Error("no catalog data")
|
||||
|
||||
const frame = await segment.readFrame()
|
||||
if (!frame) throw new Error("no catalog frame")
|
||||
|
||||
segment.close()
|
||||
return decode(path, frame)
|
||||
} finally {
|
||||
sub.close()
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeBroadcast(o: unknown): o is Broadcast {
|
||||
if (typeof o !== "object" || o === null) return false
|
||||
|
||||
const catalog = o as Partial<Broadcast>
|
||||
if (catalog.audio === undefined) catalog.audio = []
|
||||
if (!Array.isArray(catalog.audio)) return false
|
||||
if (!catalog.audio.every((track: unknown) => decodeAudio(track))) return false
|
||||
|
||||
if (catalog.video === undefined) catalog.video = []
|
||||
if (!Array.isArray(catalog.video)) return false
|
||||
if (!catalog.video.every((track: unknown) => decodeVideo(track))) return false
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { Audio } from "./audio"
|
||||
import { type Broadcast, decode, encode, fetch } from "./broadcast"
|
||||
import type { Track } from "./track"
|
||||
import type { Video } from "./video"
|
||||
|
||||
export type { Audio, Video, Track, Broadcast }
|
||||
export { encode, decode, fetch }
|
||||
@@ -1,15 +0,0 @@
|
||||
export type GroupOrder = "desc" | "asc"
|
||||
|
||||
export interface Track {
|
||||
name: string
|
||||
priority: number
|
||||
}
|
||||
|
||||
export function decodeTrack(o: unknown): o is Track {
|
||||
if (typeof o !== "object" || o === null) return false
|
||||
|
||||
const obj = o as Partial<Track>
|
||||
if (typeof obj.name !== "string") return false
|
||||
if (typeof obj.priority !== "number") return false
|
||||
return true
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import * as Hex from "../../common/hex"
|
||||
import { type Track, decodeTrack } from "./track"
|
||||
|
||||
export interface Video {
|
||||
track: Track
|
||||
codec: string
|
||||
description?: Uint8Array
|
||||
bitrate?: number
|
||||
frame_rate?: number
|
||||
resolution: Dimensions
|
||||
}
|
||||
|
||||
export interface Dimensions {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function decodeVideo(o: unknown): o is Video {
|
||||
if (typeof o !== "object" || o === null) return false
|
||||
|
||||
const obj = o as Partial<Video>
|
||||
if (!decodeTrack(obj.track)) return false
|
||||
if (typeof obj.codec !== "string") return false
|
||||
if (typeof obj.description !== "string") return false
|
||||
|
||||
obj.description = Hex.decode(obj.description)
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { Group, GroupReader } from "../transfork/model"
|
||||
import { setVint62 } from "../transfork/stream"
|
||||
|
||||
export type FrameType = "key" | "delta"
|
||||
|
||||
export class Frame {
|
||||
type: FrameType
|
||||
timestamp: number
|
||||
data: Uint8Array
|
||||
|
||||
constructor(type: FrameType, timestamp: number, data: Uint8Array) {
|
||||
this.type = type
|
||||
this.timestamp = timestamp
|
||||
this.data = data
|
||||
}
|
||||
|
||||
static async decode(group: GroupReader): Promise<Frame | undefined> {
|
||||
const kind = group.index === 0 ? "key" : "delta"
|
||||
const payload = await group.readFrame()
|
||||
if (!payload) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const [timestamp, data] = decode_timestamp(payload)
|
||||
return new Frame(kind, timestamp, data)
|
||||
}
|
||||
|
||||
encode(group: Group) {
|
||||
if ((group.length === 0) !== (this.type === "key")) {
|
||||
throw new Error(`invalid ${this.type} position`)
|
||||
}
|
||||
|
||||
let frame = new Uint8Array(8 + this.data.byteLength)
|
||||
const size = setVint62(frame, BigInt(this.timestamp)).byteLength
|
||||
frame.set(this.data, size)
|
||||
frame = new Uint8Array(frame.buffer, 0, this.data.byteLength + size)
|
||||
|
||||
group.writeFrame(frame)
|
||||
}
|
||||
}
|
||||
|
||||
// QUIC VarInt
|
||||
function decode_timestamp(buf: Uint8Array): [number, Uint8Array] {
|
||||
const size = 1 << ((buf[0] & 0xc0) >> 6)
|
||||
|
||||
const view = new DataView(buf.buffer, buf.byteOffset, size)
|
||||
const remain = new Uint8Array(buf.buffer, buf.byteOffset + size, buf.byteLength - size)
|
||||
let v: number
|
||||
|
||||
if (size === 1) {
|
||||
v = buf[0] & 0x3f
|
||||
} else if (size === 2) {
|
||||
v = view.getInt16(0) & 0x3fff
|
||||
} else if (size === 4) {
|
||||
v = view.getUint32(0) & 0x3fffffff
|
||||
} else if (size === 8) {
|
||||
// NOTE: Precision loss above 2^52
|
||||
v = Number(view.getBigUint64(0) & 0x3fffffffffffffffn)
|
||||
} else {
|
||||
throw new Error("impossible")
|
||||
}
|
||||
|
||||
return [v, remain]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["."],
|
||||
"compilerOptions": {},
|
||||
"references": [
|
||||
{
|
||||
"path": "../transfork"
|
||||
},
|
||||
{
|
||||
"path": "../common"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "@nestri/libmoq",
|
||||
"type": "module",
|
||||
"version": "0.1.4",
|
||||
"description": "Media over QUIC library",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"repository": "github:kixelated/moq-js",
|
||||
"scripts": {
|
||||
"build": "tsc -b && cp ../LICENSE* ./dist && cp ./README.md ./dist && cp ./package.json ./dist",
|
||||
"check": "biome check",
|
||||
"fix": "biome check --write"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/audioworklet": "^0.0.50",
|
||||
"@types/dom-mediacapture-transform": "^0.1.6",
|
||||
"@types/dom-webcodecs": "^0.1.8",
|
||||
"@typescript/lib-dom": "npm:@types/web@^0.0.115",
|
||||
"typescript": "^5.1.6"
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import { Ring, RingShared } from "../common/ring"
|
||||
import type * as Catalog from "../karp/catalog"
|
||||
import type { Frame } from "../karp/frame"
|
||||
import type { Component } from "./timeline"
|
||||
|
||||
// This is a non-standard way of importing worklet/workers.
|
||||
// Unfortunately, it's the only option because of a Vite bug: https://github.com/vitejs/vite/issues/11823
|
||||
import workletURL from "./worklet/index.ts?worker&url"
|
||||
|
||||
export class Renderer {
|
||||
#context: AudioContext
|
||||
#worklet: Promise<AudioWorkletNode>
|
||||
|
||||
#ring: Ring
|
||||
#ringShared: RingShared
|
||||
|
||||
#timeline: Component
|
||||
#track: Catalog.Audio
|
||||
|
||||
#decoder!: AudioDecoder
|
||||
#stream: TransformStream<Frame, AudioData>
|
||||
|
||||
constructor(track: Catalog.Audio, timeline: Component) {
|
||||
this.#track = track
|
||||
this.#context = new AudioContext({
|
||||
latencyHint: "interactive",
|
||||
sampleRate: track.sample_rate,
|
||||
})
|
||||
|
||||
this.#worklet = this.load(track)
|
||||
|
||||
this.#timeline = timeline
|
||||
this.#ringShared = new RingShared(2, track.sample_rate / 10) // 100ms
|
||||
this.#ring = new Ring(this.#ringShared)
|
||||
|
||||
this.#stream = new TransformStream({
|
||||
start: this.#start.bind(this),
|
||||
transform: this.#transform.bind(this),
|
||||
})
|
||||
|
||||
this.#run().catch((err) => console.error("failed to run audio renderer: ", err))
|
||||
}
|
||||
|
||||
private async load(catalog: Catalog.Audio): Promise<AudioWorkletNode> {
|
||||
// Load the worklet source code.
|
||||
await this.#context.audioWorklet.addModule(workletURL)
|
||||
|
||||
const volume = this.#context.createGain()
|
||||
volume.gain.value = 2.0
|
||||
|
||||
// Create the worklet
|
||||
const worklet = new AudioWorkletNode(this.#context, "renderer")
|
||||
|
||||
worklet.port.addEventListener("message", this.on.bind(this))
|
||||
worklet.onprocessorerror = (e: Event) => {
|
||||
console.error("Audio worklet error:", e)
|
||||
}
|
||||
|
||||
// Connect the worklet to the volume node and then to the speakers
|
||||
worklet.connect(volume)
|
||||
volume.connect(this.#context.destination)
|
||||
|
||||
const config = {
|
||||
sampleRate: catalog.sample_rate,
|
||||
channelCount: catalog.channel_count,
|
||||
ring: this.#ringShared,
|
||||
}
|
||||
|
||||
worklet.port.postMessage({ config })
|
||||
|
||||
return worklet
|
||||
}
|
||||
|
||||
private on(_event: MessageEvent) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
play() {
|
||||
this.#context.resume().catch((err) => console.warn("failed to resume audio context: ", err))
|
||||
}
|
||||
|
||||
close() {
|
||||
this.#context.close().catch((err) => console.warn("failed to close audio context: ", err))
|
||||
}
|
||||
|
||||
#start(controller: TransformStreamDefaultController) {
|
||||
this.#decoder = new AudioDecoder({
|
||||
output: (frame: AudioData) => {
|
||||
controller.enqueue(frame)
|
||||
},
|
||||
error: console.warn,
|
||||
})
|
||||
|
||||
// We only support OPUS right now which doesn't need a description.
|
||||
this.#decoder.configure({
|
||||
codec: this.#track.codec,
|
||||
sampleRate: this.#track.sample_rate,
|
||||
numberOfChannels: this.#track.channel_count,
|
||||
})
|
||||
}
|
||||
|
||||
#transform(frame: Frame) {
|
||||
const chunk = new EncodedAudioChunk({
|
||||
type: frame.type,
|
||||
timestamp: frame.timestamp,
|
||||
data: frame.data,
|
||||
})
|
||||
|
||||
this.#decoder.decode(chunk)
|
||||
}
|
||||
|
||||
async #run() {
|
||||
const reader = this.#timeline.frames.pipeThrough(this.#stream).getReader()
|
||||
|
||||
for (;;) {
|
||||
const { value: frame, done } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
// Write audio samples to the ring buffer, dropping when there's no space.
|
||||
const written = this.#ring.write(frame)
|
||||
|
||||
if (written < frame.numberOfFrames) {
|
||||
/*
|
||||
console.warn(
|
||||
`droppped ${frame.numberOfFrames - written} audio samples`,
|
||||
);
|
||||
*/
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import type * as Catalog from "../karp/catalog"
|
||||
import type { Connection } from "../transfork/connection"
|
||||
|
||||
import { Track } from "../transfork"
|
||||
|
||||
import { Frame } from "../karp/frame"
|
||||
import type { GroupReader } from "../transfork/model"
|
||||
import * as Audio from "./audio"
|
||||
import { Timeline } from "./timeline"
|
||||
import * as Video from "./video"
|
||||
|
||||
// This class must be created on the main thread due to AudioContext.
|
||||
export class Broadcast {
|
||||
#connection: Connection
|
||||
#catalog: Catalog.Broadcast
|
||||
|
||||
// Running is a promise that resolves when the player is closed.
|
||||
// #close is called with no error, while #abort is called with an error.
|
||||
#running: Promise<void>
|
||||
|
||||
// Timeline receives samples, buffering them and choosing the timestamp to render.
|
||||
#timeline = new Timeline()
|
||||
|
||||
#audio?: Audio.Renderer
|
||||
#video?: Video.Renderer
|
||||
|
||||
constructor(connection: Connection, catalog: Catalog.Broadcast, canvas: HTMLCanvasElement) {
|
||||
this.#connection = connection
|
||||
this.#catalog = catalog
|
||||
|
||||
const running = []
|
||||
|
||||
// Only configure audio is we have an audio track
|
||||
const audio = (catalog.audio || []).at(0)
|
||||
if (audio) {
|
||||
this.#audio = new Audio.Renderer(audio, this.#timeline.audio)
|
||||
running.push(this.#runAudio(audio))
|
||||
}
|
||||
|
||||
const video = (catalog.video || []).at(0)
|
||||
if (video) {
|
||||
this.#video = new Video.Renderer(video, canvas, this.#timeline.video)
|
||||
running.push(this.#runVideo(video))
|
||||
}
|
||||
|
||||
// Async work
|
||||
this.#running = Promise.race([...running])
|
||||
}
|
||||
|
||||
async #runAudio(audio: Catalog.Audio) {
|
||||
const track = new Track(this.#catalog.path.concat(audio.track.name), audio.track.priority)
|
||||
const sub = await this.#connection.subscribe(track)
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const group = await Promise.race([sub.nextGroup(), this.#running])
|
||||
if (!group) break
|
||||
|
||||
this.#runAudioGroup(audio, group)
|
||||
.catch(() => {})
|
||||
.finally(() => group.close())
|
||||
}
|
||||
} finally {
|
||||
sub.close()
|
||||
}
|
||||
}
|
||||
|
||||
async #runVideo(video: Catalog.Video) {
|
||||
const track = new Track(this.#catalog.path.concat(video.track.name), video.track.priority)
|
||||
const sub = await this.#connection.subscribe(track)
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const group = await Promise.race([sub.nextGroup(), this.#running])
|
||||
if (!group) break
|
||||
|
||||
this.#runVideoGroup(video, group)
|
||||
.catch(() => {})
|
||||
.finally(() => group.close())
|
||||
}
|
||||
} finally {
|
||||
sub.close()
|
||||
}
|
||||
}
|
||||
|
||||
async #runAudioGroup(audio: Catalog.Audio, group: GroupReader) {
|
||||
const timeline = this.#timeline.audio
|
||||
|
||||
// Create a queue that will contain each frame
|
||||
const queue = new TransformStream<Frame>({})
|
||||
const segment = queue.writable.getWriter()
|
||||
|
||||
// Add the segment to the timeline
|
||||
const segments = timeline.segments.getWriter()
|
||||
await segments.write({
|
||||
sequence: group.id,
|
||||
frames: queue.readable,
|
||||
})
|
||||
segments.releaseLock()
|
||||
|
||||
// Read each chunk, decoding the MP4 frames and adding them to the queue.
|
||||
for (;;) {
|
||||
const frame = await Frame.decode(group)
|
||||
if (!frame) break
|
||||
|
||||
await segment.write(frame)
|
||||
}
|
||||
|
||||
// We done.
|
||||
await segment.close()
|
||||
}
|
||||
|
||||
async #runVideoGroup(video: Catalog.Video, group: GroupReader) {
|
||||
const timeline = this.#timeline.video
|
||||
|
||||
// Create a queue that will contain each MP4 frame.
|
||||
const queue = new TransformStream<Frame>({})
|
||||
const segment = queue.writable.getWriter()
|
||||
|
||||
// Add the segment to the timeline
|
||||
const segments = timeline.segments.getWriter()
|
||||
await segments.write({
|
||||
sequence: group.id,
|
||||
frames: queue.readable,
|
||||
})
|
||||
segments.releaseLock()
|
||||
|
||||
for (;;) {
|
||||
const frame = await Frame.decode(group)
|
||||
if (!frame) break
|
||||
|
||||
await segment.write(frame)
|
||||
}
|
||||
|
||||
// We done.
|
||||
await segment.close()
|
||||
}
|
||||
|
||||
unmute() {
|
||||
console.debug("unmuting audio")
|
||||
this.#audio?.play()
|
||||
}
|
||||
|
||||
close() {
|
||||
this.#audio?.close()
|
||||
this.#video?.close()
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { Player } from "./player"
|
||||
export type { PlayerConfig } from "./player"
|
||||
@@ -1,63 +0,0 @@
|
||||
import * as Catalog from "../karp/catalog"
|
||||
import type { Connection } from "../transfork/connection"
|
||||
import { Broadcast } from "./broadcast"
|
||||
|
||||
export interface PlayerConfig {
|
||||
connection: Connection
|
||||
path: string[]
|
||||
canvas: HTMLCanvasElement
|
||||
}
|
||||
|
||||
// This class must be created on the main thread due to AudioContext.
|
||||
export class Player {
|
||||
#config: PlayerConfig
|
||||
#running: Promise<void>
|
||||
#active?: Broadcast
|
||||
|
||||
constructor(config: PlayerConfig) {
|
||||
this.#config = config
|
||||
this.#running = this.#run()
|
||||
}
|
||||
|
||||
async #run() {
|
||||
const announced = await this.#config.connection.announced(this.#config.path)
|
||||
|
||||
let activeId = -1
|
||||
|
||||
for (;;) {
|
||||
const announce = await announced.next()
|
||||
if (!announce) break
|
||||
|
||||
if (announce.path.length === this.#config.path.length) {
|
||||
throw new Error("expected resumable broadcast")
|
||||
}
|
||||
|
||||
const path = announce.path.slice(0, this.#config.path.length + 1)
|
||||
|
||||
const id = Number.parseInt(path[path.length - 1])
|
||||
if (id <= activeId) continue
|
||||
|
||||
const catalog = await Catalog.fetch(this.#config.connection, path)
|
||||
|
||||
this.#active?.close()
|
||||
this.#active = new Broadcast(this.#config.connection, catalog, this.#config.canvas)
|
||||
activeId = id
|
||||
}
|
||||
|
||||
this.#active?.close()
|
||||
}
|
||||
|
||||
close() {
|
||||
this.#config.connection.close()
|
||||
this.#active?.close()
|
||||
this.#active = undefined
|
||||
}
|
||||
|
||||
async closed() {
|
||||
await Promise.any([this.#running, this.#config.connection.closed()])
|
||||
}
|
||||
|
||||
unmute() {
|
||||
this.#active?.unmute()
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import type { Frame } from "../karp/frame"
|
||||
|
||||
export interface Range {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export class Timeline {
|
||||
// Maintain audio and video seprarately
|
||||
audio: Component
|
||||
video: Component
|
||||
|
||||
// Construct a timeline
|
||||
constructor() {
|
||||
this.audio = new Component()
|
||||
this.video = new Component()
|
||||
}
|
||||
}
|
||||
|
||||
interface Segment {
|
||||
sequence: number
|
||||
frames: ReadableStream<Frame>
|
||||
}
|
||||
|
||||
export class Component {
|
||||
#current?: Segment
|
||||
|
||||
frames: ReadableStream<Frame>
|
||||
#segments: TransformStream<Segment, Segment>
|
||||
|
||||
constructor() {
|
||||
this.frames = new ReadableStream({
|
||||
pull: this.#pull.bind(this),
|
||||
cancel: this.#cancel.bind(this),
|
||||
})
|
||||
|
||||
// This is a hack to have an async channel with 100 items.
|
||||
this.#segments = new TransformStream({}, { highWaterMark: 100 })
|
||||
}
|
||||
|
||||
get segments() {
|
||||
return this.#segments.writable
|
||||
}
|
||||
|
||||
async #pull(controller: ReadableStreamDefaultController<Frame>) {
|
||||
for (;;) {
|
||||
// Get the next segment to render.
|
||||
const segments = this.#segments.readable.getReader()
|
||||
|
||||
let res: ReadableStreamReadResult<Segment> | ReadableStreamReadResult<Frame>
|
||||
if (this.#current) {
|
||||
// Get the next frame to render.
|
||||
const frames = this.#current.frames.getReader()
|
||||
|
||||
// Wait for either the frames or segments to be ready.
|
||||
// NOTE: This assume that the first promise gets priority.
|
||||
res = await Promise.race([frames.read(), segments.read()])
|
||||
|
||||
frames.releaseLock()
|
||||
} else {
|
||||
res = await segments.read()
|
||||
}
|
||||
|
||||
segments.releaseLock()
|
||||
|
||||
const { value, done } = res
|
||||
|
||||
if (done) {
|
||||
// We assume the current segment has been closed
|
||||
// TODO support the segments stream closing
|
||||
this.#current = undefined
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isSegment(value)) {
|
||||
// Return so the reader can decide when to get the next frame.
|
||||
controller.enqueue(value)
|
||||
return
|
||||
}
|
||||
|
||||
// We didn't get any frames, and instead got a new segment.
|
||||
if (this.#current) {
|
||||
if (value.sequence < this.#current.sequence) {
|
||||
// Our segment is older than the current, abandon it.
|
||||
await value.frames.cancel("skipping segment; too old")
|
||||
continue
|
||||
}
|
||||
|
||||
// Our segment is newer than the current, cancel the old one.
|
||||
await this.#current.frames.cancel("skipping segment; too slow")
|
||||
}
|
||||
|
||||
this.#current = value
|
||||
}
|
||||
}
|
||||
|
||||
async #cancel(reason: Error) {
|
||||
if (this.#current) {
|
||||
await this.#current.frames.cancel(reason)
|
||||
}
|
||||
|
||||
const segments = this.#segments.readable.getReader()
|
||||
for (;;) {
|
||||
const { value: segment, done } = await segments.read()
|
||||
if (done) break
|
||||
|
||||
await segment.frames.cancel(reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return if a type is a segment or frame
|
||||
function isSegment(value: Segment | Frame): value is Segment {
|
||||
return (value as Segment).frames !== undefined
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["."],
|
||||
"exclude": ["./worklet"],
|
||||
"compilerOptions": {
|
||||
"types": ["dom-mediacapture-transform", "dom-webcodecs"]
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../common"
|
||||
},
|
||||
{
|
||||
"path": "../transfork"
|
||||
},
|
||||
{
|
||||
"path": "../karp"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["*"]
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import type * as Catalog from "../karp/catalog"
|
||||
import type { Frame } from "../karp/frame"
|
||||
import type { Component } from "./timeline"
|
||||
|
||||
export class Renderer {
|
||||
#track: Catalog.Video
|
||||
#canvas: HTMLCanvasElement
|
||||
#timeline: Component
|
||||
|
||||
#decoder!: VideoDecoder
|
||||
#queue: TransformStream<Frame, VideoFrame>
|
||||
|
||||
constructor(track: Catalog.Video, canvas: HTMLCanvasElement, timeline: Component) {
|
||||
this.#track = track
|
||||
this.#canvas = canvas
|
||||
this.#timeline = timeline
|
||||
|
||||
this.#queue = new TransformStream({
|
||||
start: this.#start.bind(this),
|
||||
transform: this.#transform.bind(this),
|
||||
})
|
||||
|
||||
this.#run().catch((err) => console.error("failed to run video renderer: ", err))
|
||||
}
|
||||
|
||||
close() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
async #run() {
|
||||
const reader = this.#timeline.frames.pipeThrough(this.#queue).getReader()
|
||||
for (;;) {
|
||||
const { value: frame, done } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
self.requestAnimationFrame(() => {
|
||||
this.#canvas.width = frame.displayWidth
|
||||
this.#canvas.height = frame.displayHeight
|
||||
|
||||
const ctx = this.#canvas.getContext("2d")
|
||||
if (!ctx) throw new Error("failed to get canvas context")
|
||||
|
||||
ctx.drawImage(frame, 0, 0, frame.displayWidth, frame.displayHeight) // TODO respect aspect ratio
|
||||
frame.close()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#start(controller: TransformStreamDefaultController<VideoFrame>) {
|
||||
this.#decoder = new VideoDecoder({
|
||||
output: (frame: VideoFrame) => {
|
||||
controller.enqueue(frame)
|
||||
},
|
||||
error: console.error,
|
||||
})
|
||||
|
||||
this.#decoder.configure({
|
||||
codec: this.#track.codec,
|
||||
codedHeight: this.#track.resolution.height,
|
||||
codedWidth: this.#track.resolution.width,
|
||||
description: this.#track.description,
|
||||
optimizeForLatency: true,
|
||||
})
|
||||
}
|
||||
|
||||
#transform(frame: Frame) {
|
||||
const chunk = new EncodedVideoChunk({
|
||||
type: frame.type,
|
||||
data: frame.data,
|
||||
timestamp: frame.timestamp,
|
||||
})
|
||||
|
||||
this.#decoder.decode(chunk)
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// TODO add support for @/ to avoid relative imports
|
||||
import { Ring } from "../../common/ring"
|
||||
import type * as Message from "./message"
|
||||
|
||||
class Renderer extends AudioWorkletProcessor {
|
||||
ring?: Ring
|
||||
base: number
|
||||
|
||||
constructor() {
|
||||
// The super constructor call is required.
|
||||
super()
|
||||
|
||||
this.base = 0
|
||||
this.port.onmessage = this.onMessage.bind(this)
|
||||
}
|
||||
|
||||
onMessage(e: MessageEvent) {
|
||||
const msg = e.data as Message.From
|
||||
if (msg.config) {
|
||||
this.onConfig(msg.config)
|
||||
}
|
||||
}
|
||||
|
||||
onConfig(config: Message.Config) {
|
||||
this.ring = new Ring(config.ring)
|
||||
}
|
||||
|
||||
// Inputs and outputs in groups of 128 samples.
|
||||
process(_inputs: Float32Array[][], outputs: Float32Array[][], _parameters: Record<string, Float32Array>): boolean {
|
||||
if (!this.ring) {
|
||||
// Paused
|
||||
return true
|
||||
}
|
||||
|
||||
if (outputs.length !== 1) {
|
||||
throw new Error("only a single track is supported")
|
||||
}
|
||||
|
||||
if (this.ring.size() === this.ring.capacity) {
|
||||
// This is a hack to clear any latency in the ring buffer.
|
||||
// The proper solution is to play back slightly faster?
|
||||
console.warn("resyncing ring buffer")
|
||||
this.ring.clear()
|
||||
return true
|
||||
}
|
||||
|
||||
const output = outputs[0]
|
||||
|
||||
const size = this.ring.read(output)
|
||||
if (size < output.length) {
|
||||
// TODO trigger rebuffering event
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor("renderer", Renderer)
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { RingShared } from "../../common/ring"
|
||||
|
||||
export interface From {
|
||||
config?: Config
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
channels: number
|
||||
sampleRate: number
|
||||
ring: RingShared
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["."],
|
||||
"exclude": ["./index"],
|
||||
"compilerOptions": {
|
||||
"lib": ["es2022"],
|
||||
"types": ["audioworklet"]
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../../common"
|
||||
}
|
||||
]
|
||||
}
|
||||
9
packages/moq/sst-env.d.ts
vendored
9
packages/moq/sst-env.d.ts
vendored
@@ -1,9 +0,0 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -1,63 +0,0 @@
|
||||
import * as Hex from "../common/hex"
|
||||
import { Connection } from "./connection"
|
||||
import * as Message from "./message"
|
||||
import { Stream } from "./stream"
|
||||
|
||||
export interface ClientConfig {
|
||||
url: string
|
||||
|
||||
// If set, the server fingerprint will be fetched from this URL.
|
||||
// This is required to use self-signed certificates with Chrome (May 2023)
|
||||
fingerprint?: string
|
||||
}
|
||||
|
||||
export class Client {
|
||||
#fingerprint: Promise<WebTransportHash | undefined>
|
||||
|
||||
readonly config: ClientConfig
|
||||
|
||||
constructor(config: ClientConfig) {
|
||||
this.config = config
|
||||
|
||||
this.#fingerprint = this.#fetchFingerprint(config.fingerprint).catch((e) => {
|
||||
console.warn("failed to fetch fingerprint: ", e)
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
|
||||
async connect(): Promise<Connection> {
|
||||
// Helper function to make creating a promise easier
|
||||
const options: WebTransportOptions = {}
|
||||
|
||||
const fingerprint = await this.#fingerprint
|
||||
if (fingerprint) options.serverCertificateHashes = [fingerprint]
|
||||
|
||||
const quic = new WebTransport(this.config.url, options)
|
||||
await quic.ready
|
||||
|
||||
const client = new Message.SessionClient([Message.Version.FORK_02])
|
||||
const stream = await Stream.open(quic, client)
|
||||
|
||||
const server = await Message.SessionServer.decode(stream.reader)
|
||||
if (server.version !== Message.Version.FORK_02) {
|
||||
throw new Error(`unsupported server version: ${server.version}`)
|
||||
}
|
||||
|
||||
console.log(`established connection: version=${server.version}`)
|
||||
|
||||
return new Connection(quic, stream)
|
||||
}
|
||||
|
||||
async #fetchFingerprint(url?: string): Promise<WebTransportHash | undefined> {
|
||||
if (!url) return
|
||||
|
||||
// TODO remove this fingerprint when Chrome WebTransport accepts the system CA
|
||||
const response = await fetch(url)
|
||||
const bytes = Hex.decode(await response.text())
|
||||
|
||||
return {
|
||||
algorithm: "sha-256",
|
||||
value: bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { asError } from "../common/error"
|
||||
import * as Message from "./message"
|
||||
import { Reader, Stream } from "./stream"
|
||||
|
||||
import type { Queue } from "../common/async"
|
||||
import { Closed } from "./error"
|
||||
import type { Track, TrackReader } from "./model"
|
||||
import { Publisher } from "./publisher"
|
||||
import { type Announced, Subscriber } from "./subscriber"
|
||||
|
||||
export class Connection {
|
||||
// The established WebTransport session.
|
||||
#quic: WebTransport
|
||||
|
||||
// Use to receive/send session messages.
|
||||
#session: Stream
|
||||
|
||||
// Module for contributing tracks.
|
||||
#publisher: Publisher
|
||||
|
||||
// Module for distributing tracks.
|
||||
#subscriber: Subscriber
|
||||
|
||||
// Async work running in the background
|
||||
#running: Promise<void>
|
||||
|
||||
constructor(quic: WebTransport, session: Stream) {
|
||||
this.#quic = quic
|
||||
this.#session = session
|
||||
|
||||
this.#publisher = new Publisher(this.#quic)
|
||||
this.#subscriber = new Subscriber(this.#quic)
|
||||
|
||||
this.#running = this.#run()
|
||||
}
|
||||
|
||||
close(code = 0, reason = "") {
|
||||
this.#quic.close({ closeCode: code, reason })
|
||||
}
|
||||
|
||||
async #run(): Promise<void> {
|
||||
const session = this.#runSession().catch((err) => new Error("failed to run session: ", err))
|
||||
const bidis = this.#runBidis().catch((err) => new Error("failed to run bidis: ", err))
|
||||
const unis = this.#runUnis().catch((err) => new Error("failed to run unis: ", err))
|
||||
|
||||
await Promise.all([session, bidis, unis])
|
||||
}
|
||||
|
||||
publish(track: TrackReader) {
|
||||
this.#publisher.publish(track)
|
||||
}
|
||||
|
||||
async announced(prefix: string[] = []): Promise<Queue<Announced>> {
|
||||
return this.#subscriber.announced(prefix)
|
||||
}
|
||||
|
||||
async subscribe(track: Track): Promise<TrackReader> {
|
||||
return await this.#subscriber.subscribe(track)
|
||||
}
|
||||
|
||||
async #runSession() {
|
||||
// Receive messages until the connection is closed.
|
||||
for (;;) {
|
||||
const msg = await Message.SessionInfo.decode_maybe(this.#session.reader)
|
||||
if (!msg) break
|
||||
// TODO use the session info
|
||||
}
|
||||
}
|
||||
|
||||
async #runBidis() {
|
||||
for (;;) {
|
||||
const next = await Stream.accept(this.#quic)
|
||||
if (!next) {
|
||||
break
|
||||
}
|
||||
|
||||
const [msg, stream] = next
|
||||
this.#runBidi(msg, stream).catch((err) => stream.writer.reset(Closed.extract(err)))
|
||||
}
|
||||
}
|
||||
|
||||
async #runBidi(msg: Message.Bi, stream: Stream) {
|
||||
console.debug("received bi stream: ", msg)
|
||||
|
||||
if (msg instanceof Message.SessionClient) {
|
||||
throw new Error("duplicate session stream")
|
||||
}
|
||||
|
||||
if (msg instanceof Message.AnnounceInterest) {
|
||||
if (!this.#subscriber) {
|
||||
throw new Error("not a subscriber")
|
||||
}
|
||||
|
||||
return await this.#publisher.runAnnounce(msg, stream)
|
||||
}
|
||||
if (msg instanceof Message.Subscribe) {
|
||||
if (!this.#publisher) {
|
||||
throw new Error("not a publisher")
|
||||
}
|
||||
|
||||
return await this.#publisher.runSubscribe(msg, stream)
|
||||
}
|
||||
if (msg instanceof Message.Datagrams) {
|
||||
if (!this.#publisher) {
|
||||
throw new Error("not a publisher")
|
||||
}
|
||||
|
||||
return await this.#publisher.runDatagrams(msg, stream)
|
||||
}
|
||||
if (msg instanceof Message.Fetch) {
|
||||
if (!this.#publisher) {
|
||||
throw new Error("not a publisher")
|
||||
}
|
||||
|
||||
return await this.#publisher.runFetch(msg, stream)
|
||||
}
|
||||
if (msg instanceof Message.InfoRequest) {
|
||||
if (!this.#publisher) {
|
||||
throw new Error("not a publisher")
|
||||
}
|
||||
|
||||
return await this.#publisher.runInfo(msg, stream)
|
||||
}
|
||||
}
|
||||
|
||||
async #runUnis() {
|
||||
for (;;) {
|
||||
const next = await Reader.accept(this.#quic)
|
||||
if (!next) {
|
||||
break
|
||||
}
|
||||
|
||||
const [msg, stream] = next
|
||||
this.#runUni(msg, stream).catch((err) => stream.stop(Closed.extract(err)))
|
||||
}
|
||||
}
|
||||
|
||||
async #runUni(msg: Message.Uni, stream: Reader) {
|
||||
console.debug("received uni stream: ", msg)
|
||||
|
||||
if (msg instanceof Message.Group) {
|
||||
if (!this.#subscriber) {
|
||||
throw new Error("not a subscriber")
|
||||
}
|
||||
|
||||
return this.#subscriber.runGroup(msg, stream)
|
||||
}
|
||||
}
|
||||
|
||||
async closed(): Promise<Error> {
|
||||
try {
|
||||
await this.#running
|
||||
return new Error("closed")
|
||||
} catch (e) {
|
||||
return asError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
export class Closed extends Error {
|
||||
readonly code?: number
|
||||
|
||||
constructor(code?: number) {
|
||||
super(`closed code=${code}`)
|
||||
this.code = code
|
||||
}
|
||||
|
||||
static from(err: unknown): Closed {
|
||||
return new Closed(Closed.extract(err))
|
||||
}
|
||||
|
||||
static extract(err: unknown): number {
|
||||
if (err instanceof WebTransportError && err.streamErrorCode !== null) {
|
||||
return err.streamErrorCode
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { Reader, Writer } from "./stream"
|
||||
|
||||
export class FrameReader {
|
||||
#stream: Reader
|
||||
|
||||
constructor(stream: Reader) {
|
||||
this.#stream = stream
|
||||
}
|
||||
|
||||
// Returns the next frame
|
||||
async read(): Promise<Uint8Array | undefined> {
|
||||
if (await this.#stream.done()) return
|
||||
|
||||
const size = await this.#stream.u53()
|
||||
const payload = await this.#stream.read(size)
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
async stop(code: number) {
|
||||
await this.#stream.stop(code)
|
||||
}
|
||||
}
|
||||
|
||||
export class FrameWriter {
|
||||
#stream: Writer
|
||||
|
||||
constructor(stream: Writer) {
|
||||
this.#stream = stream
|
||||
}
|
||||
|
||||
// Writes the next frame
|
||||
async write(payload: Uint8Array) {
|
||||
await this.#stream.u53(payload.byteLength)
|
||||
await this.#stream.write(payload)
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.#stream.close()
|
||||
}
|
||||
|
||||
async reset(code: number) {
|
||||
await this.#stream.reset(code)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export { Client } from "./client"
|
||||
export type { ClientConfig } from "./client"
|
||||
|
||||
export { Connection } from "./connection"
|
||||
|
||||
export { Track, Group } from "./model"
|
||||
export { Announced } from "./subscriber"
|
||||
@@ -1,428 +0,0 @@
|
||||
import type { Reader, Writer } from "./stream"
|
||||
|
||||
export enum Version {
|
||||
DRAFT_00 = 0xff000000,
|
||||
DRAFT_01 = 0xff000001,
|
||||
DRAFT_02 = 0xff000002,
|
||||
DRAFT_03 = 0xff000003,
|
||||
FORK_00 = 0xff0bad00,
|
||||
FORK_01 = 0xff0bad01,
|
||||
FORK_02 = 0xff0bad02,
|
||||
}
|
||||
|
||||
export class Extensions {
|
||||
entries: Map<bigint, Uint8Array>
|
||||
|
||||
constructor() {
|
||||
this.entries = new Map()
|
||||
}
|
||||
|
||||
set(id: bigint, value: Uint8Array) {
|
||||
this.entries.set(id, value)
|
||||
}
|
||||
|
||||
get(id: bigint): Uint8Array | undefined {
|
||||
return this.entries.get(id)
|
||||
}
|
||||
|
||||
remove(id: bigint): Uint8Array | undefined {
|
||||
const value = this.entries.get(id)
|
||||
this.entries.delete(id)
|
||||
return value
|
||||
}
|
||||
|
||||
async encode(w: Writer) {
|
||||
await w.u53(this.entries.size)
|
||||
for (const [id, value] of this.entries) {
|
||||
await w.u62(id)
|
||||
await w.u53(value.length)
|
||||
await w.write(value)
|
||||
}
|
||||
}
|
||||
|
||||
static async decode(r: Reader): Promise<Extensions> {
|
||||
const count = await r.u53()
|
||||
const params = new Extensions()
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = await r.u62()
|
||||
const size = await r.u53()
|
||||
const value = await r.read(size)
|
||||
|
||||
if (params.entries.has(id)) {
|
||||
throw new Error(`duplicate parameter id: ${id}`)
|
||||
}
|
||||
|
||||
params.entries.set(id, value)
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
}
|
||||
|
||||
export enum Order {
|
||||
Any = 0,
|
||||
Ascending = 1,
|
||||
Descending = 2,
|
||||
}
|
||||
|
||||
export class SessionClient {
|
||||
versions: Version[]
|
||||
extensions: Extensions
|
||||
|
||||
static StreamID = 0x0
|
||||
|
||||
constructor(versions: Version[], extensions = new Extensions()) {
|
||||
this.versions = versions
|
||||
this.extensions = extensions
|
||||
}
|
||||
|
||||
async encode(w: Writer) {
|
||||
await w.u53(this.versions.length)
|
||||
for (const v of this.versions) {
|
||||
await w.u53(v)
|
||||
}
|
||||
|
||||
await this.extensions.encode(w)
|
||||
}
|
||||
|
||||
static async decode(r: Reader): Promise<SessionClient> {
|
||||
const versions = []
|
||||
const count = await r.u53()
|
||||
for (let i = 0; i < count; i++) {
|
||||
versions.push(await r.u53())
|
||||
}
|
||||
|
||||
const extensions = await Extensions.decode(r)
|
||||
return new SessionClient(versions, extensions)
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionServer {
|
||||
version: Version
|
||||
extensions: Extensions
|
||||
|
||||
constructor(version: Version, extensions = new Extensions()) {
|
||||
this.version = version
|
||||
this.extensions = extensions
|
||||
}
|
||||
|
||||
async encode(w: Writer) {
|
||||
await w.u53(this.version)
|
||||
await this.extensions.encode(w)
|
||||
}
|
||||
|
||||
static async decode(r: Reader): Promise<SessionServer> {
|
||||
const version = await r.u53()
|
||||
const extensions = await Extensions.decode(r)
|
||||
return new SessionServer(version, extensions)
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionInfo {
|
||||
bitrate: number
|
||||
|
||||
constructor(bitrate: number) {
|
||||
this.bitrate = bitrate
|
||||
}
|
||||
|
||||
async encode(w: Writer) {
|
||||
await w.u53(this.bitrate)
|
||||
}
|
||||
|
||||
static async decode(r: Reader): Promise<SessionInfo> {
|
||||
const bitrate = await r.u53()
|
||||
return new SessionInfo(bitrate)
|
||||
}
|
||||
|
||||
static async decode_maybe(r: Reader): Promise<SessionInfo | undefined> {
|
||||
if (await r.done()) return
|
||||
return await SessionInfo.decode(r)
|
||||
}
|
||||
}
|
||||
|
||||
export type AnnounceStatus = "active" | "closed"
|
||||
|
||||
export class Announce {
|
||||
suffix: string[]
|
||||
status: AnnounceStatus
|
||||
|
||||
constructor(suffix: string[], status: AnnounceStatus) {
|
||||
this.suffix = suffix
|
||||
this.status = status
|
||||
}
|
||||
|
||||
async encode(w: Writer) {
|
||||
await w.u53(this.status === "active" ? 1 : 0)
|
||||
await w.path(this.suffix)
|
||||
}
|
||||
|
||||
static async decode(r: Reader): Promise<Announce> {
|
||||
const status = (await r.u53()) === 1 ? "active" : "closed"
|
||||
const suffix = await r.path()
|
||||
return new Announce(suffix, status)
|
||||
}
|
||||
|
||||
static async decode_maybe(r: Reader): Promise<Announce | undefined> {
|
||||
if (await r.done()) return
|
||||
return await Announce.decode(r)
|
||||
}
|
||||
}
|
||||
|
||||
export class AnnounceInterest {
|
||||
static StreamID = 0x1
|
||||
|
||||
constructor(public prefix: string[]) {}
|
||||
|
||||
async encode(w: Writer) {
|
||||
await w.path(this.prefix)
|
||||
}
|
||||
|
||||
static async decode(r: Reader): Promise<AnnounceInterest> {
|
||||
const prefix = await r.path()
|
||||
return new AnnounceInterest(prefix)
|
||||
}
|
||||
}
|
||||
|
||||
export class SubscribeUpdate {
|
||||
priority: number
|
||||
order = Order.Any
|
||||
expires = 0 // ms
|
||||
|
||||
start?: bigint
|
||||
end?: bigint
|
||||
|
||||
constructor(priority: number) {
|
||||
this.priority = priority
|
||||
}
|
||||
|
||||
async encode(w: Writer) {
|
||||
await w.u53(this.priority)
|
||||
await w.u53(this.order)
|
||||
await w.u53(this.expires)
|
||||
await w.u62(this.start ? this.start + 1n : 0n)
|
||||
await w.u62(this.end ? this.end + 1n : 0n)
|
||||
}
|
||||
|
||||
static async decode(r: Reader): Promise<SubscribeUpdate> {
|
||||
const priority = await r.u53()
|
||||
const order = await r.u53()
|
||||
if (order > 2) {
|
||||
throw new Error(`invalid order: ${order}`)
|
||||
}
|
||||
|
||||
const expires = await r.u53()
|
||||
const start = await r.u62()
|
||||
const end = await r.u62()
|
||||
|
||||
const update = new SubscribeUpdate(priority)
|
||||
update.order = order
|
||||
update.expires = expires
|
||||
update.start = start === 0n ? undefined : start - 1n
|
||||
update.end = end === 0n ? undefined : end - 1n
|
||||
|
||||
return update
|
||||
}
|
||||
|
||||
static async decode_maybe(r: Reader): Promise<SubscribeUpdate | undefined> {
|
||||
if (await r.done()) return
|
||||
return await SubscribeUpdate.decode(r)
|
||||
}
|
||||
}
|
||||
|
||||
export class Subscribe extends SubscribeUpdate {
|
||||
id: bigint
|
||||
path: string[]
|
||||
|
||||
static StreamID = 0x2
|
||||
|
||||
constructor(id: bigint, path: string[], priority: number) {
|
||||
super(priority)
|
||||
|
||||
this.id = id
|
||||
this.path = path
|
||||
}
|
||||
|
||||
async encode(w: Writer) {
|
||||
await w.u62(this.id)
|
||||
await w.path(this.path)
|
||||
await super.encode(w)
|
||||
}
|
||||
|
||||
static async decode(r: Reader): Promise<Subscribe> {
|
||||
const id = await r.u62()
|
||||
const path = await r.path()
|
||||
const update = await SubscribeUpdate.decode(r)
|
||||
|
||||
const subscribe = new Subscribe(id, path, update.priority)
|
||||
subscribe.order = update.order
|
||||
subscribe.expires = update.expires
|
||||
subscribe.start = update.start
|
||||
subscribe.end = update.end
|
||||
|
||||
return subscribe
|
||||
}
|
||||
}
|
||||
|
||||
export class Datagrams extends Subscribe {
|
||||
static StreamID = 0x3
|
||||
}
|
||||
|
||||
export class Info {
|
||||
priority: number
|
||||
order = Order.Descending
|
||||
expires = 0
|
||||
latest?: number
|
||||
|
||||
constructor(priority: number) {
|
||||
this.priority = priority
|
||||
}
|
||||
|
||||
async encode(w: Writer) {
|
||||
await w.u53(this.priority)
|
||||
await w.u53(this.order)
|
||||
await w.u53(this.expires)
|
||||
await w.u53(this.latest ? this.latest + 1 : 0)
|
||||
}
|
||||
|
||||
static async decode(r: Reader): Promise<Info> {
|
||||
const priority = await r.u53()
|
||||
const order = await r.u53()
|
||||
const latest = await r.u53()
|
||||
|
||||
const info = new Info(priority)
|
||||
info.latest = latest === 0 ? undefined : latest - 1
|
||||
info.order = order
|
||||
|
||||
return info
|
||||
}
|
||||
}
|
||||
|
||||
export class InfoRequest {
|
||||
path: string[]
|
||||
|
||||
static StreamID = 0x5
|
||||
|
||||
constructor(path: string[]) {
|
||||
this.path = path
|
||||
}
|
||||
|
||||
async encode(w: Writer) {
|
||||
await w.path(this.path)
|
||||
}
|
||||
|
||||
static async decode(r: Reader): Promise<InfoRequest> {
|
||||
const path = await r.path()
|
||||
return new InfoRequest(path)
|
||||
}
|
||||
}
|
||||
|
||||
export class FetchUpdate {
|
||||
priority: number
|
||||
|
||||
constructor(priority: number) {
|
||||
this.priority = priority
|
||||
}
|
||||
|
||||
async encode(w: Writer) {
|
||||
await w.u53(this.priority)
|
||||
}
|
||||
|
||||
static async decode(r: Reader): Promise<FetchUpdate> {
|
||||
return new FetchUpdate(await r.u53())
|
||||
}
|
||||
|
||||
static async decode_maybe(r: Reader): Promise<FetchUpdate | undefined> {
|
||||
if (await r.done()) return
|
||||
return await FetchUpdate.decode(r)
|
||||
}
|
||||
}
|
||||
|
||||
export class Fetch extends FetchUpdate {
|
||||
path: string[]
|
||||
|
||||
static StreamID = 0x4
|
||||
|
||||
constructor(path: string[], priority: number) {
|
||||
super(priority)
|
||||
this.path = path
|
||||
}
|
||||
|
||||
async encode(w: Writer) {
|
||||
await w.path(this.path)
|
||||
await super.encode(w)
|
||||
}
|
||||
|
||||
static async decode(r: Reader): Promise<Fetch> {
|
||||
const path = await r.path()
|
||||
const update = await FetchUpdate.decode(r)
|
||||
|
||||
const fetch = new Fetch(path, update.priority)
|
||||
return fetch
|
||||
}
|
||||
}
|
||||
|
||||
export class Group {
|
||||
subscribe: bigint
|
||||
sequence: number
|
||||
|
||||
static StreamID = 0x0
|
||||
|
||||
constructor(subscribe: bigint, sequence: number) {
|
||||
this.subscribe = subscribe
|
||||
this.sequence = sequence
|
||||
}
|
||||
|
||||
async encode(w: Writer) {
|
||||
await w.u62(this.subscribe)
|
||||
await w.u53(this.sequence)
|
||||
}
|
||||
|
||||
static async decode(r: Reader): Promise<Group> {
|
||||
return new Group(await r.u62(), await r.u53())
|
||||
}
|
||||
}
|
||||
|
||||
export class GroupDrop {
|
||||
sequence: number
|
||||
count: number
|
||||
error: number
|
||||
|
||||
constructor(sequence: number, count: number, error: number) {
|
||||
this.sequence = sequence
|
||||
this.count = count
|
||||
this.error = error
|
||||
}
|
||||
|
||||
async encode(w: Writer) {
|
||||
await w.u53(this.sequence)
|
||||
await w.u53(this.count)
|
||||
await w.u53(this.error)
|
||||
}
|
||||
|
||||
static async decode(r: Reader): Promise<GroupDrop> {
|
||||
return new GroupDrop(await r.u53(), await r.u53(), await r.u53())
|
||||
}
|
||||
}
|
||||
|
||||
export class Frame {
|
||||
payload: Uint8Array
|
||||
|
||||
constructor(payload: Uint8Array) {
|
||||
this.payload = payload
|
||||
}
|
||||
|
||||
async encode(w: Writer) {
|
||||
await w.u53(this.payload.byteLength)
|
||||
await w.write(this.payload)
|
||||
}
|
||||
|
||||
static async decode(r: Reader): Promise<Frame> {
|
||||
const size = await r.u53()
|
||||
const payload = await r.read(size)
|
||||
return new Frame(payload)
|
||||
}
|
||||
}
|
||||
|
||||
export type Bi = SessionClient | AnnounceInterest | Subscribe | Datagrams | Fetch | InfoRequest
|
||||
export type Uni = Group
|
||||
@@ -1,170 +0,0 @@
|
||||
import { Watch } from "../common/async"
|
||||
import { Closed } from "./error"
|
||||
import { Order } from "./message"
|
||||
|
||||
export class Track {
|
||||
readonly path: string[]
|
||||
readonly priority: number
|
||||
order = Order.Any
|
||||
|
||||
// TODO use an array
|
||||
latest = new Watch<GroupReader | undefined>(undefined)
|
||||
|
||||
readers = 0
|
||||
closed?: Closed
|
||||
|
||||
constructor(path: string[], priority: number) {
|
||||
this.path = path
|
||||
this.priority = priority
|
||||
}
|
||||
|
||||
appendGroup(): Group {
|
||||
const next = this.latest.value()[0]?.id ?? 0
|
||||
return this.createGroup(next)
|
||||
}
|
||||
|
||||
createGroup(sequence: number): Group {
|
||||
if (this.closed) throw this.closed
|
||||
|
||||
const group = new Group(sequence)
|
||||
const [current, _] = this.latest.value()
|
||||
|
||||
// TODO use an array
|
||||
if (!current || current.id < sequence) {
|
||||
const reader = new GroupReader(group)
|
||||
this.latest.update(reader)
|
||||
}
|
||||
|
||||
return group
|
||||
}
|
||||
|
||||
close(closed = new Closed()) {
|
||||
if (this.closed) return
|
||||
this.closed = closed
|
||||
this.latest.close()
|
||||
}
|
||||
|
||||
reader(): TrackReader {
|
||||
// VERY important that readers are closed to decrement the count
|
||||
this.readers += 1
|
||||
return new TrackReader(this)
|
||||
}
|
||||
}
|
||||
|
||||
export class TrackReader {
|
||||
latest?: number
|
||||
#track: Track
|
||||
|
||||
constructor(track: Track) {
|
||||
this.#track = track
|
||||
}
|
||||
|
||||
async nextGroup(): Promise<GroupReader | undefined> {
|
||||
let [current, next] = this.#track.latest.value()
|
||||
|
||||
for (;;) {
|
||||
if (current && this.latest !== current.id) {
|
||||
this.latest = current.id
|
||||
return current
|
||||
}
|
||||
|
||||
if (this.#track.closed) throw this.#track.closed
|
||||
|
||||
if (!next) return
|
||||
;[current, next] = await next
|
||||
}
|
||||
}
|
||||
|
||||
get path() {
|
||||
return this.#track.path
|
||||
}
|
||||
|
||||
get order() {
|
||||
return this.#track.order
|
||||
}
|
||||
|
||||
get priority() {
|
||||
return this.#track.priority
|
||||
}
|
||||
|
||||
close() {
|
||||
this.#track.readers -= 1
|
||||
if (this.#track.readers <= 0) this.#track.close()
|
||||
}
|
||||
}
|
||||
|
||||
export class Group {
|
||||
readonly id: number
|
||||
|
||||
chunks = new Watch<Uint8Array[]>([])
|
||||
readers = 0
|
||||
closed?: Closed
|
||||
|
||||
constructor(id: number) {
|
||||
this.id = id
|
||||
}
|
||||
|
||||
writeFrame(frame: Uint8Array) {
|
||||
if (this.closed) throw this.closed
|
||||
this.chunks.update((chunks) => [...chunks, frame])
|
||||
}
|
||||
|
||||
writeFrames(...frames: Uint8Array[]) {
|
||||
if (this.closed) throw this.closed
|
||||
this.chunks.update((chunks) => [...chunks, ...frames])
|
||||
this.close()
|
||||
}
|
||||
|
||||
reader(): GroupReader {
|
||||
this.readers += 1
|
||||
return new GroupReader(this)
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.chunks.value()[0].length
|
||||
}
|
||||
|
||||
close(closed = new Closed()) {
|
||||
if (this.closed) return
|
||||
this.closed = closed
|
||||
this.chunks.close()
|
||||
}
|
||||
}
|
||||
|
||||
export class GroupReader {
|
||||
#group: Group
|
||||
#index = 0
|
||||
|
||||
constructor(group: Group) {
|
||||
this.#group = group
|
||||
}
|
||||
|
||||
async readFrame(): Promise<Uint8Array | undefined> {
|
||||
let [chunks, next] = this.#group.chunks.value()
|
||||
|
||||
for (;;) {
|
||||
if (this.#index < chunks.length) {
|
||||
this.#index += 1
|
||||
return chunks[this.#index - 1]
|
||||
}
|
||||
|
||||
if (this.#group.closed) throw this.#group.closed
|
||||
|
||||
if (!next) return
|
||||
;[chunks, next] = await next
|
||||
}
|
||||
}
|
||||
|
||||
get index(): number {
|
||||
return this.#index
|
||||
}
|
||||
|
||||
get id(): number {
|
||||
return this.#group.id
|
||||
}
|
||||
|
||||
close() {
|
||||
this.#group.readers -= 1
|
||||
if (this.#group.readers <= 0) this.#group.close()
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import { Watch } from "../common/async"
|
||||
import { Closed } from "./error"
|
||||
import * as Message from "./message"
|
||||
import type { GroupReader, TrackReader } from "./model"
|
||||
import { type Stream, Writer } from "./stream"
|
||||
|
||||
export class Publisher {
|
||||
#quic: WebTransport
|
||||
|
||||
// Our announced broadcasts.
|
||||
#announce = new Map<string[], TrackReader>()
|
||||
|
||||
// Their subscribed tracks.
|
||||
#subscribe = new Map<bigint, Subscribed>()
|
||||
|
||||
constructor(quic: WebTransport) {
|
||||
this.#quic = quic
|
||||
}
|
||||
|
||||
// Publish a track
|
||||
publish(track: TrackReader) {
|
||||
if (this.#announce.has(track.path)) {
|
||||
throw new Error(`already announced: ${track.path.toString()}`)
|
||||
}
|
||||
|
||||
this.#announce.set(track.path, track)
|
||||
|
||||
// TODO: clean up announcements
|
||||
// track.closed().then(() => this.#announce.delete(track.path))
|
||||
}
|
||||
|
||||
#get(path: string[]): TrackReader | undefined {
|
||||
return this.#announce.get(path)
|
||||
}
|
||||
|
||||
async runAnnounce(msg: Message.AnnounceInterest, stream: Stream) {
|
||||
for (const announce of this.#announce.values()) {
|
||||
if (announce.path.length < msg.prefix.length) continue
|
||||
|
||||
const prefix = announce.path.slice(0, msg.prefix.length)
|
||||
if (prefix !== msg.prefix) continue
|
||||
|
||||
const suffix = announce.path.slice(msg.prefix.length)
|
||||
|
||||
const active = new Message.Announce(suffix, "active")
|
||||
await active.encode(stream.writer)
|
||||
}
|
||||
|
||||
// TODO support updates.
|
||||
// Until then, just keep the stream open.
|
||||
await stream.reader.closed()
|
||||
}
|
||||
|
||||
async runSubscribe(msg: Message.Subscribe, stream: Stream) {
|
||||
if (this.#subscribe.has(msg.id)) {
|
||||
throw new Error(`duplicate subscribe for id: ${msg.id}`)
|
||||
}
|
||||
|
||||
const track = this.#get(msg.path)
|
||||
if (!track) {
|
||||
await stream.writer.reset(404)
|
||||
return
|
||||
}
|
||||
|
||||
const subscribe = new Subscribed(msg, track, this.#quic)
|
||||
|
||||
// TODO close the stream when done
|
||||
subscribe.run().catch((err) => console.warn("failed to run subscribe: ", err))
|
||||
|
||||
try {
|
||||
const info = new Message.Info(track.priority)
|
||||
info.order = track.order
|
||||
info.latest = track.latest
|
||||
await info.encode(stream.writer)
|
||||
|
||||
for (;;) {
|
||||
// TODO try_decode
|
||||
const update = await Message.SubscribeUpdate.decode_maybe(stream.reader)
|
||||
if (!update) {
|
||||
subscribe.close()
|
||||
break
|
||||
}
|
||||
|
||||
// TODO use the update
|
||||
}
|
||||
} catch (err) {
|
||||
subscribe.close(Closed.from(err))
|
||||
}
|
||||
}
|
||||
|
||||
async runDatagrams(msg: Message.Datagrams, stream: Stream) {
|
||||
await stream.writer.reset(501)
|
||||
throw new Error("datagrams not implemented")
|
||||
}
|
||||
|
||||
async runFetch(msg: Message.Fetch, stream: Stream) {
|
||||
await stream.writer.reset(501)
|
||||
throw new Error("fetch not implemented")
|
||||
}
|
||||
|
||||
async runInfo(msg: Message.InfoRequest, stream: Stream) {
|
||||
const track = this.#get(msg.path)
|
||||
if (!track) {
|
||||
await stream.writer.reset(404)
|
||||
return
|
||||
}
|
||||
|
||||
const info = new Message.Info(track.priority)
|
||||
info.order = track.order
|
||||
info.latest = track.latest
|
||||
|
||||
await info.encode(stream.writer)
|
||||
|
||||
throw new Error("info not implemented")
|
||||
}
|
||||
}
|
||||
|
||||
class Subscribed {
|
||||
#id: bigint
|
||||
#track: TrackReader
|
||||
#quic: WebTransport
|
||||
|
||||
#closed = new Watch<Closed | undefined>(undefined)
|
||||
|
||||
constructor(msg: Message.Subscribe, track: TrackReader, quic: WebTransport) {
|
||||
this.#id = msg.id
|
||||
this.#track = track
|
||||
this.#quic = quic
|
||||
}
|
||||
|
||||
async run() {
|
||||
const closed = this.closed()
|
||||
|
||||
for (;;) {
|
||||
const [group, done] = await Promise.all([this.#track.nextGroup(), closed])
|
||||
if (done) return
|
||||
if (!group) break
|
||||
|
||||
this.#runGroup(group).catch((err) => console.warn("failed to run group: ", err))
|
||||
}
|
||||
|
||||
// TODO wait until all groups are done
|
||||
this.close()
|
||||
}
|
||||
|
||||
async #runGroup(group: GroupReader) {
|
||||
const msg = new Message.Group(this.#id, group.id)
|
||||
const stream = await Writer.open(this.#quic, msg)
|
||||
|
||||
for (;;) {
|
||||
const frame = await group.readFrame()
|
||||
if (!frame) break
|
||||
|
||||
await stream.u53(frame.byteLength)
|
||||
await stream.write(frame)
|
||||
}
|
||||
}
|
||||
|
||||
close(err = new Closed()) {
|
||||
this.#closed.update(err)
|
||||
this.#track.close()
|
||||
}
|
||||
|
||||
async closed(): Promise<Closed> {
|
||||
let [closed, next] = this.#closed.value()
|
||||
|
||||
for (;;) {
|
||||
if (closed !== undefined) return closed
|
||||
if (!next) return new Closed()
|
||||
;[closed, next] = await next
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
import * as Message from "./message"
|
||||
|
||||
const MAX_U6 = 2 ** 6 - 1
|
||||
const MAX_U14 = 2 ** 14 - 1
|
||||
const MAX_U30 = 2 ** 30 - 1
|
||||
const MAX_U31 = 2 ** 31 - 1
|
||||
const MAX_U53 = Number.MAX_SAFE_INTEGER
|
||||
const MAX_U62: bigint = 2n ** 62n - 1n
|
||||
|
||||
export class Stream {
|
||||
reader: Reader
|
||||
writer: Writer
|
||||
|
||||
constructor(props: {
|
||||
writable: WritableStream<Uint8Array>
|
||||
readable: ReadableStream<Uint8Array>
|
||||
}) {
|
||||
this.writer = new Writer(props.writable)
|
||||
this.reader = new Reader(props.readable)
|
||||
}
|
||||
|
||||
static async accept(quic: WebTransport): Promise<[Message.Bi, Stream] | undefined> {
|
||||
const reader = quic.incomingBidirectionalStreams.getReader()
|
||||
const next = await reader.read()
|
||||
reader.releaseLock()
|
||||
|
||||
if (next.done) return
|
||||
const stream = new Stream(next.value)
|
||||
let msg: Message.Bi
|
||||
|
||||
const typ = await stream.reader.u8()
|
||||
if (typ === Message.SessionClient.StreamID) {
|
||||
msg = await Message.SessionClient.decode(stream.reader)
|
||||
} else if (typ === Message.AnnounceInterest.StreamID) {
|
||||
msg = await Message.AnnounceInterest.decode(stream.reader)
|
||||
} else if (typ === Message.Subscribe.StreamID) {
|
||||
msg = await Message.Subscribe.decode(stream.reader)
|
||||
} else if (typ === Message.Datagrams.StreamID) {
|
||||
msg = await Message.Datagrams.decode(stream.reader)
|
||||
} else if (typ === Message.Fetch.StreamID) {
|
||||
msg = await Message.Fetch.decode(stream.reader)
|
||||
} else if (typ === Message.InfoRequest.StreamID) {
|
||||
msg = await Message.InfoRequest.decode(stream.reader)
|
||||
} else {
|
||||
throw new Error(`unknown stream type: ${typ}`)
|
||||
}
|
||||
|
||||
console.debug("accepted stream", msg)
|
||||
|
||||
return [msg, stream]
|
||||
}
|
||||
|
||||
static async open(quic: WebTransport, msg: Message.Bi): Promise<Stream> {
|
||||
const stream = new Stream(await quic.createBidirectionalStream())
|
||||
|
||||
if (msg instanceof Message.SessionClient) {
|
||||
await stream.writer.u8(Message.SessionClient.StreamID)
|
||||
} else if (msg instanceof Message.AnnounceInterest) {
|
||||
await stream.writer.u8(Message.AnnounceInterest.StreamID)
|
||||
} else if (msg instanceof Message.Subscribe) {
|
||||
await stream.writer.u8(Message.Subscribe.StreamID)
|
||||
} else if (msg instanceof Message.Datagrams) {
|
||||
await stream.writer.u8(Message.Datagrams.StreamID)
|
||||
} else if (msg instanceof Message.Fetch) {
|
||||
await stream.writer.u8(Message.Fetch.StreamID)
|
||||
} else if (msg instanceof Message.InfoRequest) {
|
||||
await stream.writer.u8(Message.InfoRequest.StreamID)
|
||||
} else {
|
||||
// Make sure we're not missing any types.
|
||||
const _: never = msg
|
||||
throw new Error("invalid message type")
|
||||
}
|
||||
|
||||
await msg.encode(stream.writer)
|
||||
|
||||
console.debug("opened stream", msg)
|
||||
|
||||
return stream
|
||||
}
|
||||
|
||||
async close(code?: number) {
|
||||
if (code === undefined) {
|
||||
await this.writer.close()
|
||||
} else {
|
||||
await this.writer.reset(code)
|
||||
await this.reader.stop(code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reader wraps a stream and provides convience methods for reading pieces from a stream
|
||||
// Unfortunately we can't use a BYOB reader because it's not supported with WebTransport+WebWorkers yet.
|
||||
export class Reader {
|
||||
#buffer: Uint8Array
|
||||
#stream: ReadableStream<Uint8Array>
|
||||
#reader: ReadableStreamDefaultReader<Uint8Array>
|
||||
|
||||
constructor(stream: ReadableStream<Uint8Array>, buffer = new Uint8Array()) {
|
||||
this.#buffer = buffer
|
||||
this.#stream = stream
|
||||
this.#reader = this.#stream.getReader()
|
||||
}
|
||||
|
||||
// Adds more data to the buffer, returning true if more data was added.
|
||||
async #fill(): Promise<boolean> {
|
||||
const result = await this.#reader.read()
|
||||
if (result.done) {
|
||||
return false
|
||||
}
|
||||
|
||||
const buffer = new Uint8Array(result.value)
|
||||
|
||||
if (this.#buffer.byteLength === 0) {
|
||||
this.#buffer = buffer
|
||||
} else {
|
||||
const temp = new Uint8Array(this.#buffer.byteLength + buffer.byteLength)
|
||||
temp.set(this.#buffer)
|
||||
temp.set(buffer, this.#buffer.byteLength)
|
||||
this.#buffer = temp
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Add more data to the buffer until it's at least size bytes.
|
||||
async #fillTo(size: number) {
|
||||
while (this.#buffer.byteLength < size) {
|
||||
if (!(await this.#fill())) {
|
||||
throw new Error("unexpected end of stream")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Consumes the first size bytes of the buffer.
|
||||
#slice(size: number): Uint8Array {
|
||||
const result = new Uint8Array(this.#buffer.buffer, this.#buffer.byteOffset, size)
|
||||
this.#buffer = new Uint8Array(this.#buffer.buffer, this.#buffer.byteOffset + size)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async read(size: number): Promise<Uint8Array> {
|
||||
if (size === 0) return new Uint8Array()
|
||||
|
||||
await this.#fillTo(size)
|
||||
return this.#slice(size)
|
||||
}
|
||||
|
||||
async readAll(): Promise<Uint8Array> {
|
||||
while (await this.#fill()) {}
|
||||
return this.#slice(this.#buffer.byteLength)
|
||||
}
|
||||
|
||||
async string(maxLength?: number): Promise<string> {
|
||||
const length = await this.u53()
|
||||
if (maxLength !== undefined && length > maxLength) {
|
||||
throw new Error(`string length ${length} exceeds max length ${maxLength}`)
|
||||
}
|
||||
|
||||
const buffer = await this.read(length)
|
||||
return new TextDecoder().decode(buffer)
|
||||
}
|
||||
|
||||
async path(): Promise<string[]> {
|
||||
const parts = await this.u53()
|
||||
const path = []
|
||||
|
||||
for (let i = 0; i < parts; i++) {
|
||||
path.push(await this.string())
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
async u8(): Promise<number> {
|
||||
await this.#fillTo(1)
|
||||
return this.#slice(1)[0]
|
||||
}
|
||||
|
||||
// Returns a Number using 53-bits, the max Javascript can use for integer math
|
||||
async u53(): Promise<number> {
|
||||
const v = await this.u62()
|
||||
if (v > MAX_U53) {
|
||||
throw new Error("value larger than 53-bits; use v62 instead")
|
||||
}
|
||||
|
||||
return Number(v)
|
||||
}
|
||||
|
||||
// NOTE: Returns a bigint instead of a number since it may be larger than 53-bits
|
||||
async u62(): Promise<bigint> {
|
||||
await this.#fillTo(1)
|
||||
const size = (this.#buffer[0] & 0xc0) >> 6
|
||||
|
||||
if (size === 0) {
|
||||
const first = this.#slice(1)[0]
|
||||
return BigInt(first) & 0x3fn
|
||||
}
|
||||
if (size === 1) {
|
||||
await this.#fillTo(2)
|
||||
const slice = this.#slice(2)
|
||||
const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength)
|
||||
|
||||
return BigInt(view.getInt16(0)) & 0x3fffn
|
||||
}
|
||||
if (size === 2) {
|
||||
await this.#fillTo(4)
|
||||
const slice = this.#slice(4)
|
||||
const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength)
|
||||
|
||||
return BigInt(view.getUint32(0)) & 0x3fffffffn
|
||||
}
|
||||
await this.#fillTo(8)
|
||||
const slice = this.#slice(8)
|
||||
const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength)
|
||||
|
||||
return view.getBigUint64(0) & 0x3fffffffffffffffn
|
||||
}
|
||||
|
||||
async done(): Promise<boolean> {
|
||||
if (this.#buffer.byteLength > 0) return false
|
||||
return !(await this.#fill())
|
||||
}
|
||||
|
||||
async stop(code: number) {
|
||||
this.#reader.releaseLock()
|
||||
await this.#stream.cancel(code)
|
||||
}
|
||||
|
||||
async closed() {
|
||||
return this.#reader.closed
|
||||
}
|
||||
|
||||
release(): [Uint8Array, ReadableStream<Uint8Array>] {
|
||||
this.#reader.releaseLock()
|
||||
return [this.#buffer, this.#stream]
|
||||
}
|
||||
|
||||
static async accept(quic: WebTransport): Promise<[Message.Group, Reader] | undefined> {
|
||||
const reader = quic.incomingUnidirectionalStreams.getReader()
|
||||
const next = await reader.read()
|
||||
reader.releaseLock()
|
||||
|
||||
if (next.done) return
|
||||
const stream = new Reader(next.value)
|
||||
let msg: Message.Uni
|
||||
|
||||
const typ = await stream.u8()
|
||||
if (typ === Message.Group.StreamID) {
|
||||
msg = await Message.Group.decode(stream)
|
||||
} else {
|
||||
throw new Error(`unknown stream type: ${typ}`)
|
||||
}
|
||||
|
||||
return [msg, stream]
|
||||
}
|
||||
}
|
||||
|
||||
// Writer wraps a stream and writes chunks of data
|
||||
export class Writer {
|
||||
#scratch: Uint8Array
|
||||
#writer: WritableStreamDefaultWriter<Uint8Array>
|
||||
#stream: WritableStream<Uint8Array>
|
||||
|
||||
constructor(stream: WritableStream<Uint8Array>) {
|
||||
this.#stream = stream
|
||||
this.#scratch = new Uint8Array(8)
|
||||
this.#writer = this.#stream.getWriter()
|
||||
}
|
||||
|
||||
async u8(v: number) {
|
||||
await this.write(setUint8(this.#scratch, v))
|
||||
}
|
||||
|
||||
async i32(v: number) {
|
||||
if (Math.abs(v) > MAX_U31) {
|
||||
throw new Error(`overflow, value larger than 32-bits: ${v}`)
|
||||
}
|
||||
|
||||
// We don't use a VarInt, so it always takes 4 bytes.
|
||||
// This could be improved but nothing is standardized yet.
|
||||
await this.write(setInt32(this.#scratch, v))
|
||||
}
|
||||
|
||||
async u53(v: number) {
|
||||
if (v < 0) {
|
||||
throw new Error(`underflow, value is negative: ${v}`)
|
||||
}
|
||||
if (v > MAX_U53) {
|
||||
throw new Error(`overflow, value larger than 53-bits: ${v}`)
|
||||
}
|
||||
|
||||
await this.write(setVint53(this.#scratch, v))
|
||||
}
|
||||
|
||||
async u62(v: bigint) {
|
||||
if (v < 0) {
|
||||
throw new Error(`underflow, value is negative: ${v}`)
|
||||
}
|
||||
if (v >= MAX_U62) {
|
||||
throw new Error(`overflow, value larger than 62-bits: ${v}`)
|
||||
}
|
||||
|
||||
await this.write(setVint62(this.#scratch, v))
|
||||
}
|
||||
|
||||
async write(v: Uint8Array) {
|
||||
await this.#writer.write(v)
|
||||
}
|
||||
|
||||
async string(str: string) {
|
||||
const data = new TextEncoder().encode(str)
|
||||
await this.u53(data.byteLength)
|
||||
await this.write(data)
|
||||
}
|
||||
|
||||
async path(path: string[]) {
|
||||
await this.u53(path.length)
|
||||
for (const part of path) {
|
||||
await this.string(part)
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.#writer.releaseLock()
|
||||
await this.#stream.close()
|
||||
}
|
||||
|
||||
async reset(code: number) {
|
||||
this.#writer.releaseLock()
|
||||
await this.#stream.abort(code)
|
||||
}
|
||||
|
||||
release(): WritableStream<Uint8Array> {
|
||||
this.#writer.releaseLock()
|
||||
return this.#stream
|
||||
}
|
||||
|
||||
static async open(quic: WebTransport, msg: Message.Uni): Promise<Writer> {
|
||||
const stream = new Writer(await quic.createUnidirectionalStream())
|
||||
|
||||
if (msg instanceof Message.Group) {
|
||||
await stream.u8(Message.Group.StreamID)
|
||||
} else {
|
||||
// Make sure we're not missing any types.
|
||||
const _: never = msg
|
||||
throw new Error("invalid message type")
|
||||
}
|
||||
|
||||
return stream
|
||||
}
|
||||
}
|
||||
|
||||
export function setUint8(dst: Uint8Array, v: number): Uint8Array {
|
||||
dst[0] = v
|
||||
return dst.slice(0, 1)
|
||||
}
|
||||
|
||||
export function setUint16(dst: Uint8Array, v: number): Uint8Array {
|
||||
const view = new DataView(dst.buffer, dst.byteOffset, 2)
|
||||
view.setUint16(0, v)
|
||||
|
||||
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
|
||||
}
|
||||
|
||||
export function setInt32(dst: Uint8Array, v: number): Uint8Array {
|
||||
const view = new DataView(dst.buffer, dst.byteOffset, 4)
|
||||
view.setInt32(0, v)
|
||||
|
||||
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
|
||||
}
|
||||
|
||||
export function setUint32(dst: Uint8Array, v: number): Uint8Array {
|
||||
const view = new DataView(dst.buffer, dst.byteOffset, 4)
|
||||
view.setUint32(0, v)
|
||||
|
||||
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
|
||||
}
|
||||
|
||||
export function setVint53(dst: Uint8Array, v: number): Uint8Array {
|
||||
if (v <= MAX_U6) {
|
||||
return setUint8(dst, v)
|
||||
}
|
||||
if (v <= MAX_U14) {
|
||||
return setUint16(dst, v | 0x4000)
|
||||
}
|
||||
if (v <= MAX_U30) {
|
||||
return setUint32(dst, v | 0x80000000)
|
||||
}
|
||||
if (v <= MAX_U53) {
|
||||
return setUint64(dst, BigInt(v) | 0xc000000000000000n)
|
||||
}
|
||||
throw new Error(`overflow, value larger than 53-bits: ${v}`)
|
||||
}
|
||||
|
||||
export function setVint62(dst: Uint8Array, v: bigint): Uint8Array {
|
||||
if (v < MAX_U6) {
|
||||
return setUint8(dst, Number(v))
|
||||
}
|
||||
if (v < MAX_U14) {
|
||||
return setUint16(dst, Number(v) | 0x4000)
|
||||
}
|
||||
if (v <= MAX_U30) {
|
||||
return setUint32(dst, Number(v) | 0x80000000)
|
||||
}
|
||||
if (v <= MAX_U62) {
|
||||
return setUint64(dst, BigInt(v) | 0xc000000000000000n)
|
||||
}
|
||||
throw new Error(`overflow, value larger than 62-bits: ${v}`)
|
||||
}
|
||||
|
||||
export function setUint64(dst: Uint8Array, v: bigint): Uint8Array {
|
||||
const view = new DataView(dst.buffer, dst.byteOffset, 8)
|
||||
view.setBigUint64(0, v)
|
||||
|
||||
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { Queue, Watch } from "../common/async"
|
||||
import { Closed } from "./error"
|
||||
import { FrameReader } from "./frame"
|
||||
import * as Message from "./message"
|
||||
import type { Track, TrackReader } from "./model"
|
||||
import { type Reader, Stream } from "./stream"
|
||||
|
||||
export class Subscriber {
|
||||
#quic: WebTransport
|
||||
|
||||
// Our subscribed tracks.
|
||||
#subscribe = new Map<bigint, Subscribe>()
|
||||
#subscribeNext = 0n
|
||||
|
||||
constructor(quic: WebTransport) {
|
||||
this.#quic = quic
|
||||
}
|
||||
|
||||
async announced(prefix: string[]): Promise<Queue<Announced>> {
|
||||
const announced = new Queue<Announced>()
|
||||
|
||||
const msg = new Message.AnnounceInterest(prefix)
|
||||
const stream = await Stream.open(this.#quic, msg)
|
||||
|
||||
this.runAnnounced(stream, announced, prefix)
|
||||
.then(() => announced.close())
|
||||
.catch((err) => announced.abort(err))
|
||||
|
||||
return announced
|
||||
}
|
||||
|
||||
async runAnnounced(stream: Stream, announced: Queue<Announced>, prefix: string[]) {
|
||||
const toggle: Map<string[], Announced> = new Map()
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const announce = await Message.Announce.decode_maybe(stream.reader)
|
||||
if (!announce) {
|
||||
break
|
||||
}
|
||||
|
||||
const existing = toggle.get(announce.suffix)
|
||||
if (existing) {
|
||||
if (announce.status === "active") {
|
||||
throw new Error("duplicate announce")
|
||||
}
|
||||
|
||||
existing.close()
|
||||
toggle.delete(announce.suffix)
|
||||
} else {
|
||||
if (announce.status === "closed") {
|
||||
throw new Error("unknown announce")
|
||||
}
|
||||
|
||||
const path = prefix.concat(announce.suffix)
|
||||
const item = new Announced(path)
|
||||
await announced.push(item)
|
||||
toggle.set(announce.suffix, item)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
for (const item of toggle.values()) {
|
||||
item.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Deduplicate identical subscribes
|
||||
async subscribe(track: Track): Promise<TrackReader> {
|
||||
const id = this.#subscribeNext++
|
||||
const msg = new Message.Subscribe(id, track.path, track.priority)
|
||||
|
||||
const stream = await Stream.open(this.#quic, msg)
|
||||
const subscribe = new Subscribe(id, stream, track)
|
||||
|
||||
this.#subscribe.set(subscribe.id, subscribe)
|
||||
|
||||
try {
|
||||
const ok = await Message.Info.decode(stream.reader)
|
||||
|
||||
/*
|
||||
for (;;) {
|
||||
const dropped = await Message.GroupDrop.decode(stream.reader)
|
||||
console.debug("dropped", dropped)
|
||||
}
|
||||
*/
|
||||
|
||||
return subscribe.track.reader()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
this.#subscribe.delete(subscribe.id)
|
||||
await subscribe.close(Closed.from(err))
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async runGroup(group: Message.Group, stream: Reader) {
|
||||
const subscribe = this.#subscribe.get(group.subscribe)
|
||||
if (!subscribe) return
|
||||
|
||||
const writer = subscribe.track.createGroup(group.sequence)
|
||||
|
||||
const reader = new FrameReader(stream)
|
||||
for (;;) {
|
||||
const frame = await reader.read()
|
||||
if (!frame) break
|
||||
|
||||
writer.writeFrame(frame)
|
||||
}
|
||||
|
||||
writer.close()
|
||||
}
|
||||
}
|
||||
|
||||
export class Announced {
|
||||
readonly path: string[]
|
||||
|
||||
#closed = new Watch<Closed | undefined>(undefined)
|
||||
|
||||
constructor(path: string[]) {
|
||||
this.path = path
|
||||
}
|
||||
|
||||
close(err = new Closed()) {
|
||||
this.#closed.update(err)
|
||||
}
|
||||
|
||||
async closed(): Promise<Closed> {
|
||||
let [closed, next] = this.#closed.value()
|
||||
for (;;) {
|
||||
if (closed !== undefined) return closed
|
||||
if (!next) return new Closed()
|
||||
;[closed, next] = await next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Subscribe {
|
||||
readonly id: bigint
|
||||
readonly track: Track
|
||||
readonly stream: Stream
|
||||
|
||||
// A queue of received streams for this subscription.
|
||||
#closed = new Watch<Closed | undefined>(undefined)
|
||||
|
||||
constructor(id: bigint, stream: Stream, track: Track) {
|
||||
this.id = id
|
||||
this.track = track
|
||||
this.stream = stream
|
||||
}
|
||||
|
||||
async run() {
|
||||
try {
|
||||
await this.closed()
|
||||
await this.close()
|
||||
} catch (err) {
|
||||
await this.close(Closed.from(err))
|
||||
}
|
||||
}
|
||||
|
||||
async close(closed?: Closed) {
|
||||
this.track.close(closed)
|
||||
await this.stream.close(closed?.code)
|
||||
}
|
||||
|
||||
async closed() {
|
||||
await this.stream.reader.closed()
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["."],
|
||||
"references": [
|
||||
{
|
||||
"path": "../common"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"files": [], // don't build anything with these settings.
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"module": "es2022",
|
||||
"moduleResolution": "node",
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"composite": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"isolatedModules": true,
|
||||
"types": [], // Don't automatically import any @types modules.
|
||||
"lib": ["es2022", "dom"],
|
||||
"typeRoots": ["../node_modules/@types"]
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "./common"
|
||||
},
|
||||
{
|
||||
"path": "./playback"
|
||||
},
|
||||
{
|
||||
"path": "./playback/worklet"
|
||||
},
|
||||
{
|
||||
"path": "./contribute"
|
||||
},
|
||||
{
|
||||
"path": "./transfork"
|
||||
},
|
||||
{
|
||||
"path": "./karp"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["*"]
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
diff --git a/bubblewrap.c b/bubblewrap.c
|
||||
index f8728c7..42cfe2e 100644
|
||||
--- a/bubblewrap.c
|
||||
+++ b/bubblewrap.c
|
||||
@@ -876,13 +876,6 @@ acquire_privs (void)
|
||||
/* Keep only the required capabilities for setup */
|
||||
set_required_caps ();
|
||||
}
|
||||
- else if (real_uid != 0 && has_caps ())
|
||||
- {
|
||||
- /* We have some capabilities in the non-setuid case, which should not happen.
|
||||
- Probably caused by the binary being setcap instead of setuid which we
|
||||
- don't support anymore */
|
||||
- die ("Unexpected capabilities but not setuid, old file caps config?");
|
||||
- }
|
||||
else if (real_uid == 0)
|
||||
{
|
||||
/* If our uid is 0, default to inheriting all caps; the caller
|
||||
@@ -1,4 +0,0 @@
|
||||
.astro/
|
||||
.idea/
|
||||
dist/
|
||||
node_modules/
|
||||
25
packages/play-standalone/.gitignore
vendored
25
packages/play-standalone/.gitignore
vendored
@@ -1,25 +0,0 @@
|
||||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# setting folders
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -1,20 +0,0 @@
|
||||
// @ts-check
|
||||
import { defineConfig, envField } from "astro/config";
|
||||
import node from "@astrojs/node";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
adapter: node({
|
||||
mode: 'standalone',
|
||||
}),
|
||||
output: "server",
|
||||
server: {
|
||||
"host": "0.0.0.0",
|
||||
"port": 3000,
|
||||
},
|
||||
env: {
|
||||
schema: {
|
||||
PEER_URL: envField.string({ context: "server", access: "secret", optional: true }),
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "play-standalone",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "9.5.0",
|
||||
"@nestri/input": "*",
|
||||
"astro": "5.15.1"
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="48.672001"
|
||||
height="36.804001"
|
||||
viewBox="0 0 12.8778 9.7377253"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="layer1">
|
||||
<path
|
||||
d="m 2.093439,1.7855532 h 8.690922 V 2.2639978 H 2.093439 Z m 0,2.8440874 h 8.690922 V 5.1080848 H 2.093439 Z m 0,2.8440866 h 8.690922 V 7.952172 H 2.093439 Z"
|
||||
style="font-size:12px;fill:#ff4f01;fill-opacity:1;fill-rule:evenodd;stroke:#ff4f01;stroke-width:1.66201;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 590 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,35 +0,0 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg"/>
|
||||
<meta name="viewport" content="width=device-width"/>
|
||||
<meta name="generator" content={Astro.generator}/>
|
||||
<title>Nestri Standalone Play</title>
|
||||
</head>
|
||||
<body>
|
||||
<slot></slot>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Basement Grotesque";
|
||||
src: url("/fonts/BasementGrotesque-Black.woff") format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: "Basement Grotesque", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #191919;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,193 +0,0 @@
|
||||
---
|
||||
import DefaultLayout from "../layouts/DefaultLayout.astro";
|
||||
const { room } = Astro.params;
|
||||
|
||||
// Passing of environment variables to the client side
|
||||
// gotta love node and it's ecosystem..
|
||||
const envs_map: Map<string, string | undefined> = new Map();
|
||||
import { PEER_URL, getSecret } from "astro:env/server";
|
||||
if (PEER_URL) {
|
||||
envs_map.set("PEER_URL", getSecret("PEER_URL"));
|
||||
}
|
||||
|
||||
let envs: string = "";
|
||||
if (envs_map.size > 0) {
|
||||
envs = JSON.stringify(Array.from(envs_map.entries()));
|
||||
}
|
||||
---
|
||||
|
||||
<DefaultLayout>
|
||||
<h1 id="offlineText" class="offline">Offline</h1>
|
||||
<h1 id="loadingText" class="loading">Warming up the GPU...</h1>
|
||||
<canvas id="playCanvas" class="playCanvas" data-room={room}></canvas>
|
||||
<div id="ENVS" data-envs={envs}></div>
|
||||
</DefaultLayout>
|
||||
|
||||
<script>
|
||||
import { Mouse, Keyboard, Controller, WebRTCStream } from "@nestri/input";
|
||||
const ENVS = document.getElementById("ENVS")!.dataset.envs as string;
|
||||
let ENVS_MAP: Map<string, string | undefined> | null = null;
|
||||
if (ENVS && ENVS.length > 0) {
|
||||
ENVS_MAP = new Map(JSON.parse(ENVS));
|
||||
console.debug("ENVS_MAP:", ENVS_MAP);
|
||||
}
|
||||
|
||||
// Method which returns true if mobile device
|
||||
const isMobile = () => {
|
||||
return /Mobi|Android/i.test(navigator.userAgent);
|
||||
};
|
||||
|
||||
// Elements
|
||||
const canvas = document.getElementById("playCanvas")! as HTMLCanvasElement;
|
||||
const offlineText = document.getElementById("offlineText")! as HTMLHeadingElement;
|
||||
const loadingText = document.getElementById("loadingText")! as HTMLHeadingElement;
|
||||
|
||||
const room = canvas.dataset.room;
|
||||
if (!room || room.length <= 0) {
|
||||
throw new Error("Room parameter is required");
|
||||
}
|
||||
|
||||
offlineText.style.display = "flex";
|
||||
loadingText.style.display = "none";
|
||||
|
||||
// Get query parameter "peerURL" from the URL
|
||||
let peerURL = new URLSearchParams(window.location.search).get("peerURL");
|
||||
if (!peerURL || peerURL.length <= 0) {
|
||||
peerURL = (ENVS_MAP && ENVS_MAP.get("PEER_URL")) ?? "/dnsaddr/relay.dathorse.com/p2p/12D3KooWPK4v5wKYNYx9oXWjqLM8Xix6nm13o91j1Feqq98fLBsw";
|
||||
}
|
||||
|
||||
console.debug("Using Peer URL:", peerURL);
|
||||
|
||||
// Stream
|
||||
const stream = new WebRTCStream(peerURL, room, async (mediaStream) => {
|
||||
if (mediaStream && video.srcObject === null) {
|
||||
video.srcObject = mediaStream;
|
||||
offlineText.style.display = "none";
|
||||
loadingText.style.display = "flex";
|
||||
|
||||
await video.play().catch((e) => {
|
||||
console.error("Failed to play video:", e);
|
||||
});
|
||||
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const renderer = () => {
|
||||
if (ctx && video.srcObject) {
|
||||
ctx.drawImage(video, 0, 0);
|
||||
video.requestVideoFrameCallback(renderer);
|
||||
}
|
||||
};
|
||||
video.requestVideoFrameCallback(renderer);
|
||||
loadingText.style.display = "none";
|
||||
}
|
||||
});
|
||||
const video = document.createElement("video") as HTMLVideoElement;
|
||||
|
||||
// Input
|
||||
let nestriMouse: Mouse | null = null;
|
||||
let nestriKeyboard: Keyboard | null = null;
|
||||
let nestriControllers: Controller[] = [];
|
||||
|
||||
window.addEventListener("gamepadconnected", (e) => {
|
||||
console.log("Gamepad connected:", e.gamepad);
|
||||
const controller = new Controller({
|
||||
webrtc: stream,
|
||||
e: e,
|
||||
});
|
||||
nestriControllers.push(controller);
|
||||
});
|
||||
window.addEventListener("gamepaddisconnected", (e) => {
|
||||
console.log("Gamepad disconnected:", e.gamepad);
|
||||
let disconnected = nestriControllers.find((c) => c.getSlot() === e.gamepad.index);
|
||||
if (disconnected) {
|
||||
disconnected.dispose();
|
||||
nestriControllers = nestriControllers.filter((c) => c !== disconnected);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("pointerlockchange", () => {
|
||||
if (document.pointerLockElement === canvas) {
|
||||
if (nestriMouse)
|
||||
return;
|
||||
|
||||
nestriMouse = new Mouse({
|
||||
canvas: canvas,
|
||||
webrtc: stream,
|
||||
});
|
||||
} else {
|
||||
if (nestriMouse) {
|
||||
nestriMouse.dispose();
|
||||
nestriMouse = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("fullscreenchange", () => {
|
||||
if (document.fullscreenElement) {
|
||||
if (nestriKeyboard)
|
||||
return;
|
||||
|
||||
nestriKeyboard = new Keyboard({
|
||||
webrtc: stream,
|
||||
});
|
||||
|
||||
nestriControllers.forEach((c) => c.run());
|
||||
|
||||
if ("keyboard" in navigator && "lock" in (navigator.keyboard as any)) {
|
||||
const keys = [
|
||||
"AltLeft", "AltRight", "Tab", "Escape",
|
||||
"ContextMenu", "MetaLeft", "MetaRight"
|
||||
];
|
||||
|
||||
(navigator.keyboard as any).lock(keys).then(() => {
|
||||
console.log("Keyboard lock acquired");
|
||||
}).catch((err: any) => {
|
||||
console.error("Failed to acquire keyboard lock:", err);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (nestriKeyboard) {
|
||||
nestriKeyboard.dispose();
|
||||
nestriKeyboard = null;
|
||||
}
|
||||
nestriControllers.forEach((c) => c.stop());
|
||||
}
|
||||
})
|
||||
|
||||
const lockPlay = async function () {
|
||||
if (document.fullscreenElement)
|
||||
return;
|
||||
|
||||
await canvas.requestFullscreen();
|
||||
if (!isMobile())
|
||||
await canvas.requestPointerLock();
|
||||
};
|
||||
|
||||
canvas.addEventListener("click", lockPlay);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.playCanvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100vh;
|
||||
object-fit: contain;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.offline, .loading {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: lightgray;
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
9
packages/play-standalone/sst-env.d.ts
vendored
9
packages/play-standalone/sst-env.d.ts
vendored
@@ -1,9 +0,0 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
persist-data/
|
||||
1
packages/relay/.gitignore
vendored
1
packages/relay/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
persist-data/
|
||||
@@ -1,104 +0,0 @@
|
||||
module relay
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/libp2p/go-libp2p v0.44.0
|
||||
github.com/libp2p/go-libp2p-pubsub v0.15.0
|
||||
github.com/libp2p/go-reuseport v0.4.0
|
||||
github.com/multiformats/go-multiaddr v0.16.1
|
||||
github.com/oklog/ulid/v2 v2.1.1
|
||||
github.com/pion/ice/v4 v4.0.10
|
||||
github.com/pion/interceptor v0.1.41
|
||||
github.com/pion/rtp v1.8.25
|
||||
github.com/pion/webrtc/v4 v4.1.6
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
google.golang.org/protobuf v1.36.10
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/benbjohnson/clock v1.3.5 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/filecoin-project/go-clock v0.1.0 // indirect
|
||||
github.com/flynn/noise v1.1.0 // indirect
|
||||
github.com/francoispqt/gojay v1.2.13 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/huin/goupnp v1.3.0 // indirect
|
||||
github.com/ipfs/go-cid v0.6.0 // indirect
|
||||
github.com/jackpal/go-nat-pmp v1.0.2 // indirect
|
||||
github.com/jbenet/go-temp-err-catcher v0.1.0 // indirect
|
||||
github.com/klauspost/compress v1.18.1 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/koron/go-ssdp v0.1.0 // indirect
|
||||
github.com/libp2p/go-buffer-pool v0.1.0 // indirect
|
||||
github.com/libp2p/go-flow-metrics v0.3.0 // indirect
|
||||
github.com/libp2p/go-libp2p-asn-util v0.4.1 // indirect
|
||||
github.com/libp2p/go-msgio v0.3.0 // indirect
|
||||
github.com/libp2p/go-netroute v0.4.0 // indirect
|
||||
github.com/libp2p/go-yamux/v5 v5.1.0 // indirect
|
||||
github.com/libp2p/zeroconf/v2 v2.2.0 // indirect
|
||||
github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd // indirect
|
||||
github.com/miekg/dns v1.1.68 // indirect
|
||||
github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b // indirect
|
||||
github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc // indirect
|
||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/multiformats/go-base32 v0.1.0 // indirect
|
||||
github.com/multiformats/go-base36 v0.2.0 // indirect
|
||||
github.com/multiformats/go-multiaddr-dns v0.4.1 // indirect
|
||||
github.com/multiformats/go-multiaddr-fmt v0.1.0 // indirect
|
||||
github.com/multiformats/go-multibase v0.2.0 // indirect
|
||||
github.com/multiformats/go-multicodec v0.10.0 // indirect
|
||||
github.com/multiformats/go-multihash v0.2.3 // indirect
|
||||
github.com/multiformats/go-multistream v0.6.1 // indirect
|
||||
github.com/multiformats/go-varint v0.1.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect
|
||||
github.com/pion/datachannel v1.5.10 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.12 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.7 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.16 // indirect
|
||||
github.com/pion/sctp v1.8.40 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.16 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.8 // indirect
|
||||
github.com/pion/stun v0.6.1 // indirect
|
||||
github.com/pion/stun/v3 v3.0.1 // indirect
|
||||
github.com/pion/transport/v2 v2.2.10 // indirect
|
||||
github.com/pion/transport/v3 v3.0.8 // indirect
|
||||
github.com/pion/turn/v4 v4.1.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.2 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.55.0 // indirect
|
||||
github.com/quic-go/webtransport-go v0.9.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.13.1 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
go.uber.org/dig v1.19.0 // indirect
|
||||
go.uber.org/fx v1.24.0 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20251028164327-d7a2859f34e8 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
lukechampine.com/blake3 v1.4.1 // indirect
|
||||
)
|
||||
@@ -1,473 +0,0 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.37.0/go.mod h1:TS1dMSSfndXH133OKGwekG838Om/cQT0BUHV3HcBgoo=
|
||||
dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl+fi1br7+Rr3LqpNJf1/uxUdtRUV+Tnj0o93V2B9MU=
|
||||
dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU=
|
||||
dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4=
|
||||
dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU=
|
||||
git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
|
||||
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
|
||||
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g=
|
||||
github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c h1:pFUpOrbxDR6AkioZ1ySsx5yxlDQZ8stG2b88gTPxgJU=
|
||||
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c/go.mod h1:6UhI8N9EjYm1c2odKpFpAYeR8dsBeM7PtzQhRgxRr9U=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/filecoin-project/go-clock v0.1.0 h1:SFbYIM75M8NnFm1yMHhN9Ahy3W5bEZV9gd6MPfXbKVU=
|
||||
github.com/filecoin-project/go-clock v0.1.0/go.mod h1:4uB/O4PvOjlx1VCMdZ9MyDZXRm//gkj1ELEbxfI1AZs=
|
||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
|
||||
github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg=
|
||||
github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag=
|
||||
github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
|
||||
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
|
||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY=
|
||||
github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
|
||||
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
|
||||
github.com/ipfs/go-cid v0.6.0 h1:DlOReBV1xhHBhhfy/gBNNTSyfOM6rLiIx9J7A4DGf30=
|
||||
github.com/ipfs/go-cid v0.6.0/go.mod h1:NC4kS1LZjzfhK40UGmpXv5/qD2kcMzACYJNntCUiDhQ=
|
||||
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
|
||||
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
|
||||
github.com/jbenet/go-temp-err-catcher v0.1.0 h1:zpb3ZH6wIE8Shj2sKS+khgRvf7T7RABoLk/+KKHggpk=
|
||||
github.com/jbenet/go-temp-err-catcher v0.1.0/go.mod h1:0kJRvmDZXNMIiJirNPEYfhpPwbGVtZVWC34vc5WLsDk=
|
||||
github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/koron/go-ssdp v0.1.0 h1:ckl5x5H6qSNFmi+wCuROvvGUu2FQnMbQrU95IHCcv3Y=
|
||||
github.com/koron/go-ssdp v0.1.0/go.mod h1:GltaDBjtK1kemZOusWYLGotV0kBeEf59Bp0wtSB0uyU=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8=
|
||||
github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg=
|
||||
github.com/libp2p/go-flow-metrics v0.3.0 h1:q31zcHUvHnwDO0SHaukewPYgwOBSxtt830uJtUx6784=
|
||||
github.com/libp2p/go-flow-metrics v0.3.0/go.mod h1:nuhlreIwEguM1IvHAew3ij7A8BMlyHQJ279ao24eZZo=
|
||||
github.com/libp2p/go-libp2p v0.44.0 h1:5Gtt8OrF8yiXmH+Mx4+/iBeFRMK1TY3a8OrEBDEqAvs=
|
||||
github.com/libp2p/go-libp2p v0.44.0/go.mod h1:NovCojezAt4dnDd4fH048K7PKEqH0UFYYqJRjIIu8zc=
|
||||
github.com/libp2p/go-libp2p-asn-util v0.4.1 h1:xqL7++IKD9TBFMgnLPZR6/6iYhawHKHl950SO9L6n94=
|
||||
github.com/libp2p/go-libp2p-asn-util v0.4.1/go.mod h1:d/NI6XZ9qxw67b4e+NgpQexCIiFYJjErASrYW4PFDN8=
|
||||
github.com/libp2p/go-libp2p-pubsub v0.15.0 h1:cG7Cng2BT82WttmPFMi50gDNV+58K626m/wR00vGL1o=
|
||||
github.com/libp2p/go-libp2p-pubsub v0.15.0/go.mod h1:lr4oE8bFgQaifRcoc2uWhWWiK6tPdOEKpUuR408GFN4=
|
||||
github.com/libp2p/go-libp2p-testing v0.12.0 h1:EPvBb4kKMWO29qP4mZGyhVzUyR25dvfUIK5WDu6iPUA=
|
||||
github.com/libp2p/go-libp2p-testing v0.12.0/go.mod h1:KcGDRXyN7sQCllucn1cOOS+Dmm7ujhfEyXQL5lvkcPg=
|
||||
github.com/libp2p/go-msgio v0.3.0 h1:mf3Z8B1xcFN314sWX+2vOTShIE0Mmn2TXn3YCUQGNj0=
|
||||
github.com/libp2p/go-msgio v0.3.0/go.mod h1:nyRM819GmVaF9LX3l03RMh10QdOroF++NBbxAb0mmDM=
|
||||
github.com/libp2p/go-netroute v0.4.0 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT7/Q=
|
||||
github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA=
|
||||
github.com/libp2p/go-reuseport v0.4.0 h1:nR5KU7hD0WxXCJbmw7r2rhRYruNRl2koHw8fQscQm2s=
|
||||
github.com/libp2p/go-reuseport v0.4.0/go.mod h1:ZtI03j/wO5hZVDFo2jKywN6bYKWLOy8Se6DrI2E1cLU=
|
||||
github.com/libp2p/go-yamux/v5 v5.1.0 h1:8Qlxj4E9JGJAQVW6+uj2o7mqkqsIVlSUGmTWhlXzoHE=
|
||||
github.com/libp2p/go-yamux/v5 v5.1.0/go.mod h1:tgIQ07ObtRR/I0IWsFOyQIL9/dR5UXgc2s8xKmNZv1o=
|
||||
github.com/libp2p/zeroconf/v2 v2.2.0 h1:Cup06Jv6u81HLhIj1KasuNM/RHHrJ8T7wOTS4+Tv53Q=
|
||||
github.com/libp2p/zeroconf/v2 v2.2.0/go.mod h1:fuJqLnUwZTshS3U/bMRJ3+ow/v9oid1n0DmyYyNO1Xs=
|
||||
github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
|
||||
github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/marcopolo/simnet v0.0.1 h1:rSMslhPz6q9IvJeFWDoMGxMIrlsbXau3NkuIXHGJxfg=
|
||||
github.com/marcopolo/simnet v0.0.1/go.mod h1:WDaQkgLAjqDUEBAOXz22+1j6wXKfGlC5sD5XWt3ddOs=
|
||||
github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd h1:br0buuQ854V8u83wA0rVZ8ttrq5CpaPZdvrK0LP2lOk=
|
||||
github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd/go.mod h1:QuCEs1Nt24+FYQEqAAncTDPJIuGs+LxK1MCiFL25pMU=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4=
|
||||
github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4=
|
||||
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
|
||||
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
|
||||
github.com/mikioh/tcp v0.0.0-20190314235350-803a9b46060c h1:bzE/A84HN25pxAuk9Eej1Kz9OUelF97nAc82bDquQI8=
|
||||
github.com/mikioh/tcp v0.0.0-20190314235350-803a9b46060c/go.mod h1:0SQS9kMwD2VsyFEB++InYyBJroV/FRmBgcydeSUcJms=
|
||||
github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b h1:z78hV3sbSMAUoyUMM0I83AUIT6Hu17AWfgjzIbtrYFc=
|
||||
github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b/go.mod h1:lxPUiZwKoFL8DUUmalo2yJJUCxbPKtm8OKfqr2/FTNU=
|
||||
github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc h1:PTfri+PuQmWDqERdnNMiD9ZejrlswWrCpBEZgWOiTrc=
|
||||
github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc/go.mod h1:cGKTAVKx4SxOuR/czcZ/E2RSJ3sfHs8FpHhQ5CWMf9s=
|
||||
github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8RvIylQ358TN4wwqatJ8rNavkEINozVn9DtGI3dfQ=
|
||||
github.com/minio/sha256-simd v0.1.1-0.20190913151208-6de447530771/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM=
|
||||
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
|
||||
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/mr-tron/base58 v1.1.2/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
|
||||
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
|
||||
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
|
||||
github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4=
|
||||
github.com/multiformats/go-multiaddr v0.1.1/go.mod h1:aMKBKNEYmzmDmxfX88/vz+J5IU55txyt0p4aiWVohjo=
|
||||
github.com/multiformats/go-multiaddr v0.16.1 h1:fgJ0Pitow+wWXzN9do+1b8Pyjmo8m5WhGfzpL82MpCw=
|
||||
github.com/multiformats/go-multiaddr v0.16.1/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0=
|
||||
github.com/multiformats/go-multiaddr-dns v0.4.1 h1:whi/uCLbDS3mSEUMb1MsoT4uzUeZB0N32yzufqS0i5M=
|
||||
github.com/multiformats/go-multiaddr-dns v0.4.1/go.mod h1:7hfthtB4E4pQwirrz+J0CcDUfbWzTqEzVyYKKIKpgkc=
|
||||
github.com/multiformats/go-multiaddr-fmt v0.1.0 h1:WLEFClPycPkp4fnIzoFoV9FVd49/eQsuaL3/CWe167E=
|
||||
github.com/multiformats/go-multiaddr-fmt v0.1.0/go.mod h1:hGtDIW4PU4BqJ50gW2quDuPVjyWNZxToGUh/HwTZYJo=
|
||||
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
|
||||
github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk=
|
||||
github.com/multiformats/go-multicodec v0.10.0 h1:UpP223cig/Cx8J76jWt91njpK3GTAO1w02sdcjZDSuc=
|
||||
github.com/multiformats/go-multicodec v0.10.0/go.mod h1:wg88pM+s2kZJEQfRCKBNU+g32F5aWBEjyFHXvZLTcLI=
|
||||
github.com/multiformats/go-multihash v0.0.8/go.mod h1:YSLudS+Pi8NHE7o6tb3D8vrpKa63epEDmG8nTduyAew=
|
||||
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
|
||||
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
|
||||
github.com/multiformats/go-multistream v0.6.1 h1:4aoX5v6T+yWmc2raBHsTvzmFhOI8WVOer28DeBBEYdQ=
|
||||
github.com/multiformats/go-multistream v0.6.1/go.mod h1:ksQf6kqHAb6zIsyw7Zm+gAuVo57Qbq84E27YlYqavqw=
|
||||
github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI=
|
||||
github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
|
||||
github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
|
||||
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
||||
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||
github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
||||
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||
github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk=
|
||||
github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
|
||||
github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q=
|
||||
github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8=
|
||||
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
|
||||
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
|
||||
github.com/pion/interceptor v0.1.41 h1:NpvX3HgWIukTf2yTBVjVGFXtpSpWgXjqz7IIpu7NsOw=
|
||||
github.com/pion/interceptor v0.1.41/go.mod h1:nEt4187unvRXJFyjiw00GKo+kIuXMWQI9K89fsosDLY=
|
||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
|
||||
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
|
||||
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
|
||||
github.com/pion/rtp v1.8.25 h1:b8+y44GNbwOJTYWuVan7SglX/hMlicVCAtL50ztyZHw=
|
||||
github.com/pion/rtp v1.8.25/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
|
||||
github.com/pion/sctp v1.8.40 h1:bqbgWYOrUhsYItEnRObUYZuzvOMsVplS3oNgzedBlG8=
|
||||
github.com/pion/sctp v1.8.40/go.mod h1:SPBBUENXE6ThkEksN5ZavfAhFYll+h+66ZiG6IZQuzo=
|
||||
github.com/pion/sdp/v3 v3.0.16 h1:0dKzYO6gTAvuLaAKQkC02eCPjMIi4NuAr/ibAwrGDCo=
|
||||
github.com/pion/sdp/v3 v3.0.16/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
|
||||
github.com/pion/srtp/v3 v3.0.8 h1:RjRrjcIeQsilPzxvdaElN0CpuQZdMvcl9VZ5UY9suUM=
|
||||
github.com/pion/srtp/v3 v3.0.8/go.mod h1:2Sq6YnDH7/UDCvkSoHSDNDeyBcFgWL0sAVycVbAsXFg=
|
||||
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
|
||||
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
|
||||
github.com/pion/stun/v3 v3.0.1 h1:jx1uUq6BdPihF0yF33Jj2mh+C9p0atY94IkdnW174kA=
|
||||
github.com/pion/stun/v3 v3.0.1/go.mod h1:RHnvlKFg+qHgoKIqtQWMOJF52wsImCAf/Jh5GjX+4Tw=
|
||||
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
|
||||
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
||||
github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q=
|
||||
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
|
||||
github.com/pion/transport/v3 v3.0.8 h1:oI3myyYnTKUSTthu/NZZ8eu2I5sHbxbUNNFW62olaYc=
|
||||
github.com/pion/transport/v3 v3.0.8/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
|
||||
github.com/pion/turn/v4 v4.1.2 h1:Em2svpl6aBFa88dLhxypMUzaLjC79kWZWx8FIov01cc=
|
||||
github.com/pion/turn/v4 v4.1.2/go.mod h1:ISYWfZYy0Z3tXzRpyYZHTL+U23yFQIspfxogdQ8pn9Y=
|
||||
github.com/pion/webrtc/v4 v4.1.6 h1:srHH2HwvCGwPba25EYJgUzgLqCQoXl1VCUnrGQMSzUw=
|
||||
github.com/pion/webrtc/v4 v4.1.6/go.mod h1:wKecGRlkl3ox/As/MYghJL+b/cVXMEhoPMJWPuGQFhU=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
|
||||
github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
|
||||
github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
|
||||
github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
||||
github.com/quic-go/webtransport-go v0.9.0 h1:jgys+7/wm6JarGDrW+lD/r9BGqBAmqY/ssklE09bA70=
|
||||
github.com/quic-go/webtransport-go v0.9.0/go.mod h1:4FUYIiUc75XSsF6HShcLeXXYZJ9AGwo/xh3L8M/P1ao=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||
github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY=
|
||||
github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM=
|
||||
github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0=
|
||||
github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
|
||||
github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ=
|
||||
github.com/shurcooL/gofontwoff v0.0.0-20180329035133-29b52fc0a18d/go.mod h1:05UtEgK5zq39gLST6uB0cf3NEHjETfB4Fgr3Gx5R9Vw=
|
||||
github.com/shurcooL/gopherjslib v0.0.0-20160914041154-feb6d3990c2c/go.mod h1:8d3azKNyqcHP1GaQE/c6dDgjkgSx2BZ4IoEi4F1reUI=
|
||||
github.com/shurcooL/highlight_diff v0.0.0-20170515013008-09bb4053de1b/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU=
|
||||
github.com/shurcooL/highlight_go v0.0.0-20181028180052-98c3abbbae20/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag=
|
||||
github.com/shurcooL/home v0.0.0-20181020052607-80b7ffcb30f9/go.mod h1:+rgNQw2P9ARFAs37qieuu7ohDNQ3gds9msbT2yn85sg=
|
||||
github.com/shurcooL/htmlg v0.0.0-20170918183704-d01228ac9e50/go.mod h1:zPn1wHpTIePGnXSHpsVPWEktKXHr6+SS6x/IKRb7cpw=
|
||||
github.com/shurcooL/httperror v0.0.0-20170206035902-86b7830d14cc/go.mod h1:aYMfkZ6DWSJPJ6c4Wwz3QtW22G7mf/PEgaB9k/ik5+Y=
|
||||
github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
|
||||
github.com/shurcooL/httpgzip v0.0.0-20180522190206-b1c53ac65af9/go.mod h1:919LwcH0M7/W4fcZ0/jy0qGght1GIhqyS/EgWGH2j5Q=
|
||||
github.com/shurcooL/issues v0.0.0-20181008053335-6292fdc1e191/go.mod h1:e2qWDig5bLteJ4fwvDAc2NHzqFEthkqn7aOZAOpj+PQ=
|
||||
github.com/shurcooL/issuesapp v0.0.0-20180602232740-048589ce2241/go.mod h1:NPpHK2TI7iSaM0buivtFUc9offApnI0Alt/K8hcHy0I=
|
||||
github.com/shurcooL/notifications v0.0.0-20181007000457-627ab5aea122/go.mod h1:b5uSkrEVM1jQUspwbixRBhaIjIzL2xazXp6kntxYle0=
|
||||
github.com/shurcooL/octicon v0.0.0-20181028054416-fa4f57f9efb2/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ=
|
||||
github.com/shurcooL/reactions v0.0.0-20181006231557-f2e0b4ca5b82/go.mod h1:TCR1lToEk4d2s07G3XGfz2QrgHXg4RJBvjrOozvoWfk=
|
||||
github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4=
|
||||
github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw=
|
||||
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE=
|
||||
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA=
|
||||
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
|
||||
github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU=
|
||||
github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM=
|
||||
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA=
|
||||
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
|
||||
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
|
||||
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
|
||||
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE=
|
||||
golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw=
|
||||
golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200602180216-279210d13fed/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181029044818-c44066c5c816/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210426080607-c94f62235c83/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20251028164327-d7a2859f34e8 h1:DwMAzqwLj2rVin75cRFh1kfhwQY3hyHrU1oCEDZXPmQ=
|
||||
golang.org/x/telemetry v0.0.0-20251028164327-d7a2859f34e8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
|
||||
google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
|
||||
google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg=
|
||||
google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
|
||||
google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio=
|
||||
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o=
|
||||
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
|
||||
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
|
||||
sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck=
|
||||
sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0=
|
||||
@@ -1,217 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/pion/interceptor/pkg/nack"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"github.com/libp2p/go-reuseport"
|
||||
"github.com/pion/ice/v4"
|
||||
"github.com/pion/interceptor"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
var globalWebRTCAPI *webrtc.API
|
||||
var globalWebRTCConfig = webrtc.Configuration{
|
||||
ICETransportPolicy: webrtc.ICETransportPolicyAll,
|
||||
BundlePolicy: webrtc.BundlePolicyBalanced,
|
||||
SDPSemantics: webrtc.SDPSemanticsUnifiedPlan,
|
||||
}
|
||||
|
||||
func InitWebRTCAPI() error {
|
||||
var err error
|
||||
flags := GetFlags()
|
||||
|
||||
// Media engine
|
||||
mediaEngine := &webrtc.MediaEngine{}
|
||||
|
||||
// Register our extensions
|
||||
if err = RegisterExtensions(mediaEngine); err != nil {
|
||||
return fmt.Errorf("failed to register extensions: %w", err)
|
||||
}
|
||||
|
||||
// Register codecs
|
||||
for _, codec := range []webrtc.RTPCodecParameters{
|
||||
{
|
||||
RTPCodecCapability: webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus, ClockRate: 48000, Channels: 2, SDPFmtpLine: "minptime=10;useinbandfec=1"},
|
||||
PayloadType: 111,
|
||||
},
|
||||
} {
|
||||
if err = mediaEngine.RegisterCodec(codec, webrtc.RTPCodecTypeAudio); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
videoRTCPFeedback := []webrtc.RTCPFeedback{{"nack", ""}, {"nack", "pli"}}
|
||||
for _, codec := range []webrtc.RTPCodecParameters{
|
||||
{
|
||||
RTPCodecCapability: webrtc.RTPCodecCapability{
|
||||
MimeType: webrtc.MimeTypeH264, ClockRate: 90000,
|
||||
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f",
|
||||
RTCPFeedback: videoRTCPFeedback,
|
||||
},
|
||||
PayloadType: 102,
|
||||
},
|
||||
{
|
||||
RTPCodecCapability: webrtc.RTPCodecCapability{
|
||||
MimeType: webrtc.MimeTypeH264, ClockRate: 90000,
|
||||
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f",
|
||||
RTCPFeedback: videoRTCPFeedback,
|
||||
},
|
||||
PayloadType: 104,
|
||||
},
|
||||
{
|
||||
RTPCodecCapability: webrtc.RTPCodecCapability{
|
||||
MimeType: webrtc.MimeTypeH264, ClockRate: 90000,
|
||||
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
|
||||
RTCPFeedback: videoRTCPFeedback,
|
||||
},
|
||||
PayloadType: 106,
|
||||
},
|
||||
{
|
||||
RTPCodecCapability: webrtc.RTPCodecCapability{
|
||||
MimeType: webrtc.MimeTypeH264, ClockRate: 90000,
|
||||
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f",
|
||||
RTCPFeedback: videoRTCPFeedback,
|
||||
},
|
||||
PayloadType: 108,
|
||||
},
|
||||
{
|
||||
RTPCodecCapability: webrtc.RTPCodecCapability{
|
||||
MimeType: webrtc.MimeTypeH264, ClockRate: 90000,
|
||||
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f",
|
||||
RTCPFeedback: videoRTCPFeedback,
|
||||
},
|
||||
PayloadType: 127,
|
||||
},
|
||||
{
|
||||
RTPCodecCapability: webrtc.RTPCodecCapability{
|
||||
MimeType: webrtc.MimeTypeH264,
|
||||
ClockRate: 90000,
|
||||
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=4d001f",
|
||||
RTCPFeedback: videoRTCPFeedback,
|
||||
},
|
||||
PayloadType: 39,
|
||||
},
|
||||
{
|
||||
RTPCodecCapability: webrtc.RTPCodecCapability{
|
||||
MimeType: webrtc.MimeTypeH265,
|
||||
ClockRate: 90000,
|
||||
RTCPFeedback: videoRTCPFeedback,
|
||||
},
|
||||
PayloadType: 116,
|
||||
},
|
||||
{
|
||||
RTPCodecCapability: webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeAV1, ClockRate: 90000, RTCPFeedback: videoRTCPFeedback},
|
||||
PayloadType: 45,
|
||||
},
|
||||
{
|
||||
RTPCodecCapability: webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP9, ClockRate: 90000, SDPFmtpLine: "profile-id=0", RTCPFeedback: videoRTCPFeedback},
|
||||
PayloadType: 98,
|
||||
},
|
||||
{
|
||||
RTPCodecCapability: webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP9, ClockRate: 90000, SDPFmtpLine: "profile-id=2", RTCPFeedback: videoRTCPFeedback},
|
||||
PayloadType: 100,
|
||||
},
|
||||
{
|
||||
RTPCodecCapability: webrtc.RTPCodecCapability{
|
||||
MimeType: webrtc.MimeTypeH264, ClockRate: 90000,
|
||||
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f",
|
||||
RTCPFeedback: videoRTCPFeedback,
|
||||
},
|
||||
PayloadType: 112,
|
||||
},
|
||||
} {
|
||||
if err = mediaEngine.RegisterCodec(codec, webrtc.RTPCodecTypeVideo); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Interceptor registry
|
||||
interceptorRegistry := &interceptor.Registry{}
|
||||
|
||||
// Register our interceptors..
|
||||
nackGenFactory, err := nack.NewGeneratorInterceptor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
interceptorRegistry.Add(nackGenFactory)
|
||||
nackRespFactory, err := nack.NewResponderInterceptor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
interceptorRegistry.Add(nackRespFactory)
|
||||
|
||||
if err = webrtc.ConfigureRTCPReports(interceptorRegistry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Setting engine
|
||||
settingEngine := webrtc.SettingEngine{}
|
||||
|
||||
// New in v4, reduces CPU usage and latency when enabled
|
||||
settingEngine.EnableSCTPZeroChecksum(true)
|
||||
|
||||
nat11IP := GetFlags().NAT11IP
|
||||
if len(nat11IP) > 0 {
|
||||
settingEngine.SetNAT1To1IPs([]string{nat11IP}, webrtc.ICECandidateTypeHost)
|
||||
slog.Info("Using NAT 1:1 IP for WebRTC", "nat11_ip", nat11IP)
|
||||
}
|
||||
|
||||
muxPort := GetFlags().UDPMuxPort
|
||||
if muxPort > 0 {
|
||||
// Use reuseport to allow multiple listeners on the same port
|
||||
pktListener, err := reuseport.ListenPacket("udp", ":"+strconv.Itoa(muxPort))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create WebRTC muxed UDP listener: %w", err)
|
||||
}
|
||||
|
||||
mux := ice.NewMultiUDPMuxDefault(ice.NewUDPMuxDefault(ice.UDPMuxParams{
|
||||
UDPConn: pktListener,
|
||||
}))
|
||||
slog.Info("Using UDP Mux for WebRTC", "port", muxPort)
|
||||
settingEngine.SetICEUDPMux(mux)
|
||||
}
|
||||
|
||||
if flags.WebRTCUDPStart > 0 && flags.WebRTCUDPEnd > 0 && flags.WebRTCUDPStart < flags.WebRTCUDPEnd {
|
||||
// Set the UDP port range used by WebRTC
|
||||
err = settingEngine.SetEphemeralUDPPortRange(uint16(flags.WebRTCUDPStart), uint16(flags.WebRTCUDPEnd))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("Using WebRTC UDP Port Range", "start", flags.WebRTCUDPStart, "end", flags.WebRTCUDPEnd)
|
||||
}
|
||||
|
||||
// Improves speed when sending offers to browsers (https://github.com/pion/webrtc/issues/3174)
|
||||
settingEngine.SetIncludeLoopbackCandidate(true)
|
||||
|
||||
// Create a new API object with our customized settings
|
||||
globalWebRTCAPI = webrtc.NewAPI(webrtc.WithMediaEngine(mediaEngine), webrtc.WithSettingEngine(settingEngine), webrtc.WithInterceptorRegistry(interceptorRegistry))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreatePeerConnection sets up a new peer connection
|
||||
func CreatePeerConnection(onClose func()) (*webrtc.PeerConnection, error) {
|
||||
pc, err := globalWebRTCAPI.NewPeerConnection(globalWebRTCConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Log connection state changes and handle failed/disconnected connections
|
||||
pc.OnConnectionStateChange(func(connectionState webrtc.PeerConnectionState) {
|
||||
// Close PeerConnection in cases
|
||||
if connectionState == webrtc.PeerConnectionStateFailed ||
|
||||
connectionState == webrtc.PeerConnectionStateDisconnected ||
|
||||
connectionState == webrtc.PeerConnectionStateClosed {
|
||||
err = pc.Close()
|
||||
if err != nil {
|
||||
slog.Error("Failed to close PeerConnection", "err", err)
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
})
|
||||
|
||||
return pc, nil
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
func NewULID() (ulid.ULID, error) {
|
||||
return ulid.New(ulid.Timestamp(time.Now()), ulid.Monotonic(rand.Reader, 0))
|
||||
}
|
||||
|
||||
// GenerateED25519Key generates a new ED25519 key
|
||||
func GenerateED25519Key() (ed25519.PrivateKey, error) {
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate ED25519 key pair: %w", err)
|
||||
}
|
||||
return priv, nil
|
||||
}
|
||||
|
||||
// SaveED25519Key saves an ED25519 private key to a path as a binary file
|
||||
func SaveED25519Key(privateKey ed25519.PrivateKey, filePath string) error {
|
||||
if privateKey == nil {
|
||||
return errors.New("private key cannot be nil")
|
||||
}
|
||||
if len(privateKey) != ed25519.PrivateKeySize {
|
||||
return errors.New("private key must be exactly 64 bytes for ED25519")
|
||||
}
|
||||
if err := os.WriteFile(filePath, privateKey, 0600); err != nil {
|
||||
return fmt.Errorf("failed to save ED25519 key to %s: %w", filePath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadED25519Key loads an ED25519 private key binary file from a path
|
||||
func LoadED25519Key(filePath string) (ed25519.PrivateKey, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read ED25519 key from %s: %w", filePath, err)
|
||||
}
|
||||
if len(data) != ed25519.PrivateKeySize {
|
||||
return nil, fmt.Errorf("ED25519 key must be exactly %d bytes, got %d", ed25519.PrivateKeySize, len(data))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package common
|
||||
|
||||
import "github.com/pion/webrtc/v4"
|
||||
|
||||
const (
|
||||
ExtensionPlayoutDelay string = "http://www.webrtc.org/experiments/rtp-hdrext/playout-delay"
|
||||
)
|
||||
|
||||
// ExtensionMap maps audio/video extension URIs to their IDs based on registration order
|
||||
var ExtensionMap = map[webrtc.RTPCodecType]map[string]uint8{}
|
||||
|
||||
func RegisterExtensions(mediaEngine *webrtc.MediaEngine) error {
|
||||
// Register additional header extensions to reduce latency
|
||||
// Playout Delay (Video)
|
||||
if err := mediaEngine.RegisterHeaderExtension(webrtc.RTPHeaderExtensionCapability{
|
||||
URI: ExtensionPlayoutDelay,
|
||||
}, webrtc.RTPCodecTypeVideo); err != nil {
|
||||
return err
|
||||
}
|
||||
// Playout Delay (Audio)
|
||||
if err := mediaEngine.RegisterHeaderExtension(webrtc.RTPHeaderExtensionCapability{
|
||||
URI: ExtensionPlayoutDelay,
|
||||
}, webrtc.RTPCodecTypeAudio); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Register the extension IDs for both audio and video
|
||||
ExtensionMap[webrtc.RTPCodecTypeAudio] = map[string]uint8{
|
||||
ExtensionPlayoutDelay: 1,
|
||||
}
|
||||
ExtensionMap[webrtc.RTPCodecTypeVideo] = map[string]uint8{
|
||||
ExtensionPlayoutDelay: 1,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetExtension(codecType webrtc.RTPCodecType, extURI string) (uint8, bool) {
|
||||
cType, ok := ExtensionMap[codecType]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
extID, ok := cType[extURI]
|
||||
return extID, ok
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
var globalFlags *Flags
|
||||
|
||||
type Flags struct {
|
||||
RegenIdentity bool // Remove old identity on startup and regenerate it
|
||||
Verbose bool // Log everything to console
|
||||
Debug bool // Enable debug mode, implies Verbose
|
||||
EndpointPort int // Port for HTTP/S and WS/S endpoint (TCP)
|
||||
WebRTCUDPStart int // WebRTC UDP port range start - ignored if UDPMuxPort is set
|
||||
WebRTCUDPEnd int // WebRTC UDP port range end - ignored if UDPMuxPort is set
|
||||
STUNServer string // WebRTC STUN server
|
||||
UDPMuxPort int // WebRTC UDP mux port - if set, overrides UDP port range
|
||||
AutoAddLocalIP bool // Automatically add local IP to NAT 1 to 1 IPs
|
||||
NAT11IP string // WebRTC NAT 1 to 1 IP - allows specifying IP of relay if behind NAT
|
||||
PersistDir string // Directory to save persistent data to
|
||||
Metrics bool // Enable metrics endpoint
|
||||
MetricsPort int // Port for metrics endpoint
|
||||
}
|
||||
|
||||
func (flags *Flags) DebugLog() {
|
||||
slog.Debug("Relay flags",
|
||||
"regenIdentity", flags.RegenIdentity,
|
||||
"verbose", flags.Verbose,
|
||||
"debug", flags.Debug,
|
||||
"endpointPort", flags.EndpointPort,
|
||||
"webrtcUDPStart", flags.WebRTCUDPStart,
|
||||
"webrtcUDPEnd", flags.WebRTCUDPEnd,
|
||||
"stunServer", flags.STUNServer,
|
||||
"webrtcUDPMux", flags.UDPMuxPort,
|
||||
"autoAddLocalIP", flags.AutoAddLocalIP,
|
||||
"webrtcNAT11IPs", flags.NAT11IP,
|
||||
"persistDir", flags.PersistDir,
|
||||
"metrics", flags.Metrics,
|
||||
"metricsPort", flags.MetricsPort,
|
||||
)
|
||||
}
|
||||
|
||||
func getEnvAsInt(name string, defaultVal int) int {
|
||||
valueStr := os.Getenv(name)
|
||||
if value, err := strconv.Atoi(valueStr); err != nil {
|
||||
return defaultVal
|
||||
} else {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func getEnvAsBool(name string, defaultVal bool) bool {
|
||||
valueStr := os.Getenv(name)
|
||||
val, err := strconv.ParseBool(valueStr)
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func getEnvAsString(name string, defaultVal string) string {
|
||||
valueStr := os.Getenv(name)
|
||||
if len(valueStr) == 0 {
|
||||
return defaultVal
|
||||
}
|
||||
return valueStr
|
||||
}
|
||||
|
||||
func InitFlags() {
|
||||
// Create Flags struct
|
||||
globalFlags = &Flags{}
|
||||
// Get flags
|
||||
flag.BoolVar(&globalFlags.RegenIdentity, "regenIdentity", getEnvAsBool("REGEN_IDENTITY", false), "Regenerate identity on startup")
|
||||
flag.BoolVar(&globalFlags.Verbose, "verbose", getEnvAsBool("VERBOSE", false), "Verbose mode")
|
||||
flag.BoolVar(&globalFlags.Debug, "debug", getEnvAsBool("DEBUG", false), "Debug mode")
|
||||
flag.IntVar(&globalFlags.EndpointPort, "endpointPort", getEnvAsInt("ENDPOINT_PORT", 8088), "HTTP endpoint port")
|
||||
flag.IntVar(&globalFlags.WebRTCUDPStart, "webrtcUDPStart", getEnvAsInt("WEBRTC_UDP_START", 0), "WebRTC UDP port range start")
|
||||
flag.IntVar(&globalFlags.WebRTCUDPEnd, "webrtcUDPEnd", getEnvAsInt("WEBRTC_UDP_END", 0), "WebRTC UDP port range end")
|
||||
flag.StringVar(&globalFlags.STUNServer, "stunServer", getEnvAsString("STUN_SERVER", "stun.l.google.com:19302"), "WebRTC STUN server")
|
||||
flag.IntVar(&globalFlags.UDPMuxPort, "webrtcUDPMux", getEnvAsInt("WEBRTC_UDP_MUX", 9099), "WebRTC UDP mux port")
|
||||
flag.BoolVar(&globalFlags.AutoAddLocalIP, "autoAddLocalIP", getEnvAsBool("AUTO_ADD_LOCAL_IP", false), "Automatically add local IP to NAT 1 to 1 IPs")
|
||||
// String with comma separated IPs
|
||||
nat11IP := ""
|
||||
flag.StringVar(&nat11IP, "webrtcNAT11IP", getEnvAsString("WEBRTC_NAT_IP", ""), "WebRTC NAT 1 to 1 IP")
|
||||
flag.StringVar(&globalFlags.PersistDir, "persistDir", getEnvAsString("PERSIST_DIR", "./persist-data"), "Directory to save persistent data to")
|
||||
flag.BoolVar(&globalFlags.Metrics, "metrics", getEnvAsBool("METRICS", false), "Enable metrics endpoint")
|
||||
flag.IntVar(&globalFlags.MetricsPort, "metricsPort", getEnvAsInt("METRICS_PORT", 3030), "Port for metrics endpoint")
|
||||
// Parse flags
|
||||
flag.Parse()
|
||||
|
||||
// If debug is enabled, verbose is also enabled
|
||||
if globalFlags.Debug {
|
||||
globalFlags.Verbose = true
|
||||
}
|
||||
|
||||
// ICE STUN servers
|
||||
globalWebRTCConfig.ICEServers = []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:" + globalFlags.STUNServer},
|
||||
},
|
||||
}
|
||||
|
||||
// Parse NAT 1 to 1 IPs from string
|
||||
if len(nat11IP) > 0 {
|
||||
globalFlags.NAT11IP = nat11IP
|
||||
} else if globalFlags.AutoAddLocalIP {
|
||||
globalFlags.NAT11IP = getLocalIP()
|
||||
}
|
||||
}
|
||||
|
||||
func GetFlags() *Flags {
|
||||
return globalFlags
|
||||
}
|
||||
|
||||
// getLocalIP returns local IP, be it either IPv4 or IPv6, skips loopback addresses
|
||||
func getLocalIP() string {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, address := range addrs {
|
||||
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && !ipnet.IP.IsPrivate() && !ipnet.IP.IsUnspecified() {
|
||||
if ipnet.IP.To4() != nil || ipnet.IP != nil {
|
||||
return ipnet.IP.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
// ICEHelper holds webrtc.ICECandidateInit(s) until remote candidate is set for given webrtc.PeerConnection
|
||||
// Held candidates should be flushed at the end of negotiation to ensure all are available for connection
|
||||
type ICEHelper struct {
|
||||
candidates []webrtc.ICECandidateInit
|
||||
pc *webrtc.PeerConnection
|
||||
}
|
||||
|
||||
func NewICEHelper(pc *webrtc.PeerConnection) *ICEHelper {
|
||||
return &ICEHelper{
|
||||
pc: pc,
|
||||
candidates: make([]webrtc.ICECandidateInit, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (ice *ICEHelper) SetPeerConnection(pc *webrtc.PeerConnection) {
|
||||
ice.pc = pc
|
||||
}
|
||||
|
||||
func (ice *ICEHelper) AddCandidate(c webrtc.ICECandidateInit) {
|
||||
if ice.pc != nil {
|
||||
if ice.pc.RemoteDescription() != nil {
|
||||
// Add immediately if remote is set
|
||||
if err := ice.pc.AddICECandidate(c); err != nil {
|
||||
slog.Error("Failed to add ICE candidate", "err", err)
|
||||
}
|
||||
// Also flush held candidates automatically
|
||||
ice.FlushHeldCandidates()
|
||||
} else {
|
||||
// Hold in slice until remote is set
|
||||
ice.candidates = append(ice.candidates, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ice *ICEHelper) FlushHeldCandidates() {
|
||||
if ice.pc != nil && len(ice.candidates) > 0 {
|
||||
for _, heldCandidate := range ice.candidates {
|
||||
if err := ice.pc.AddICECandidate(heldCandidate); err != nil {
|
||||
slog.Error("Failed to add held ICE candidate", "err", err)
|
||||
}
|
||||
}
|
||||
// Clear the held candidates
|
||||
ice.candidates = make([]webrtc.ICECandidateInit, 0)
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
gen "relay/internal/proto"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
type TimestampEntry struct {
|
||||
Stage string `json:"stage"`
|
||||
Time time.Time `json:"time"`
|
||||
}
|
||||
|
||||
// LatencyTracker provides a generic structure for measuring time taken at various stages in message processing.
|
||||
// It can be embedded in message structs for tracking the flow of data and calculating round-trip latency.
|
||||
type LatencyTracker struct {
|
||||
SequenceID string `json:"sequence_id"`
|
||||
Timestamps []TimestampEntry `json:"timestamps"`
|
||||
}
|
||||
|
||||
// NewLatencyTracker initializes a new LatencyTracker with the given sequence ID
|
||||
func NewLatencyTracker(sequenceID string) *LatencyTracker {
|
||||
return &LatencyTracker{
|
||||
SequenceID: sequenceID,
|
||||
Timestamps: make([]TimestampEntry, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// AddTimestamp adds a new timestamp for a specific stage
|
||||
func (lt *LatencyTracker) AddTimestamp(stage string) {
|
||||
lt.Timestamps = append(lt.Timestamps, TimestampEntry{
|
||||
Stage: stage,
|
||||
// Ensure extremely precise UTC RFC3339 timestamps (down to nanoseconds)
|
||||
Time: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
// TotalLatency calculates the total latency from the earliest to the latest timestamp
|
||||
func (lt *LatencyTracker) TotalLatency() (int64, error) {
|
||||
if len(lt.Timestamps) < 2 {
|
||||
return 0, nil // Not enough timestamps to calculate latency
|
||||
}
|
||||
|
||||
var earliest, latest time.Time
|
||||
for _, ts := range lt.Timestamps {
|
||||
if earliest.IsZero() || ts.Time.Before(earliest) {
|
||||
earliest = ts.Time
|
||||
}
|
||||
if latest.IsZero() || ts.Time.After(latest) {
|
||||
latest = ts.Time
|
||||
}
|
||||
}
|
||||
|
||||
return latest.Sub(earliest).Milliseconds(), nil
|
||||
}
|
||||
|
||||
// PainPoints returns a list of stages where the duration exceeds the given threshold.
|
||||
func (lt *LatencyTracker) PainPoints(threshold time.Duration) []string {
|
||||
var painPoints []string
|
||||
var lastStage string
|
||||
var lastTime time.Time
|
||||
|
||||
for _, ts := range lt.Timestamps {
|
||||
stage := ts.Stage
|
||||
if lastStage == "" {
|
||||
lastStage = stage
|
||||
lastTime = ts.Time
|
||||
continue
|
||||
}
|
||||
|
||||
currentTime := ts.Time
|
||||
if currentTime.Sub(lastTime) > threshold {
|
||||
painPoints = append(painPoints, fmt.Sprintf("%s -> %s", lastStage, stage))
|
||||
}
|
||||
|
||||
lastStage = stage
|
||||
lastTime = currentTime
|
||||
}
|
||||
return painPoints
|
||||
}
|
||||
|
||||
// StageLatency calculates the time taken between two specific stages.
|
||||
func (lt *LatencyTracker) StageLatency(startStage, endStage string) (time.Duration, error) {
|
||||
var startTime, endTime time.Time
|
||||
for _, ts := range lt.Timestamps {
|
||||
if ts.Stage == startStage {
|
||||
startTime = ts.Time
|
||||
}
|
||||
if ts.Stage == endStage {
|
||||
endTime = ts.Time
|
||||
}
|
||||
}
|
||||
|
||||
/*if startTime == "" || endTime == "" {
|
||||
return 0, fmt.Errorf("missing timestamps for stages: %s -> %s", startStage, endStage)
|
||||
}*/
|
||||
|
||||
return endTime.Sub(startTime), nil
|
||||
}
|
||||
|
||||
func LatencyTrackerFromProto(protolt *gen.ProtoLatencyTracker) *LatencyTracker {
|
||||
ret := &LatencyTracker{
|
||||
SequenceID: protolt.GetSequenceId(),
|
||||
Timestamps: make([]TimestampEntry, 0),
|
||||
}
|
||||
|
||||
for _, ts := range protolt.GetTimestamps() {
|
||||
ret.Timestamps = append(ret.Timestamps, TimestampEntry{
|
||||
Stage: ts.GetStage(),
|
||||
Time: ts.GetTime().AsTime(),
|
||||
})
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func (lt *LatencyTracker) ToProto() *gen.ProtoLatencyTracker {
|
||||
ret := &gen.ProtoLatencyTracker{
|
||||
SequenceId: lt.SequenceID,
|
||||
Timestamps: make([]*gen.ProtoTimestampEntry, len(lt.Timestamps)),
|
||||
}
|
||||
|
||||
for i, timestamp := range lt.Timestamps {
|
||||
ret.Timestamps[i] = &gen.ProtoTimestampEntry{
|
||||
Stage: timestamp.Stage,
|
||||
Time: timestamppb.New(timestamp.Time),
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CustomHandler struct {
|
||||
Handler slog.Handler
|
||||
}
|
||||
|
||||
func (h *CustomHandler) Enabled(_ context.Context, level slog.Level) bool {
|
||||
return h.Handler.Enabled(nil, level)
|
||||
}
|
||||
|
||||
func (h *CustomHandler) Handle(_ context.Context, r slog.Record) error {
|
||||
// Format the timestamp as "2006/01/02 15:04:05"
|
||||
timestamp := r.Time.Format("2006/01/02 15:04:05")
|
||||
// Convert level to uppercase string (e.g., "INFO")
|
||||
level := strings.ToUpper(r.Level.String())
|
||||
// Build the message
|
||||
msg := fmt.Sprintf("%s %s %s", timestamp, level, r.Message)
|
||||
|
||||
// Handle additional attributes if they exist
|
||||
var attrs []string
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
attrs = append(attrs, fmt.Sprintf("%s=%v", a.Key, a.Value))
|
||||
return true
|
||||
})
|
||||
if len(attrs) > 0 {
|
||||
msg += " " + strings.Join(attrs, " ")
|
||||
}
|
||||
|
||||
// Write the formatted message to stdout
|
||||
_, err := fmt.Fprintln(os.Stdout, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *CustomHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return &CustomHandler{Handler: h.Handler.WithAttrs(attrs)}
|
||||
}
|
||||
|
||||
func (h *CustomHandler) WithGroup(name string) slog.Handler {
|
||||
return &CustomHandler{Handler: h.Handler.WithGroup(name)}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
gen "relay/internal/proto"
|
||||
"sync"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
// readUvarint reads an unsigned varint from the reader
|
||||
func readUvarint(r io.ByteReader) (uint64, error) {
|
||||
return binary.ReadUvarint(r)
|
||||
}
|
||||
|
||||
// writeUvarint writes an unsigned varint to the writer
|
||||
func writeUvarint(w io.Writer, x uint64) error {
|
||||
buf := make([]byte, binary.MaxVarintLen64)
|
||||
n := binary.PutUvarint(buf, x)
|
||||
_, err := w.Write(buf[:n])
|
||||
return err
|
||||
}
|
||||
|
||||
// SafeBufioRW wraps a bufio.ReadWriter for sending and receiving JSON and protobufs safely
|
||||
type SafeBufioRW struct {
|
||||
brw *bufio.ReadWriter
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
func NewSafeBufioRW(brw *bufio.ReadWriter) *SafeBufioRW {
|
||||
return &SafeBufioRW{brw: brw}
|
||||
}
|
||||
|
||||
func (bu *SafeBufioRW) SendProto(msg proto.Message) error {
|
||||
bu.mutex.Lock()
|
||||
defer bu.mutex.Unlock()
|
||||
|
||||
protoData, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write varint length prefix
|
||||
if err := writeUvarint(bu.brw, uint64(len(protoData))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write the Protobuf data
|
||||
if _, err := bu.brw.Write(protoData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return bu.brw.Flush()
|
||||
}
|
||||
|
||||
func (bu *SafeBufioRW) ReceiveProto(msg proto.Message) error {
|
||||
bu.mutex.RLock()
|
||||
defer bu.mutex.RUnlock()
|
||||
|
||||
// Read varint length prefix
|
||||
length, err := readUvarint(bu.brw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Read the Protobuf data
|
||||
data := make([]byte, length)
|
||||
if _, err := io.ReadFull(bu.brw, data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return proto.Unmarshal(data, msg)
|
||||
}
|
||||
|
||||
type CreateMessageOptions struct {
|
||||
SequenceID string
|
||||
Latency *gen.ProtoLatencyTracker
|
||||
}
|
||||
|
||||
func CreateMessage(payload proto.Message, payloadType string, opts *CreateMessageOptions) (*gen.ProtoMessage, error) {
|
||||
msg := &gen.ProtoMessage{
|
||||
MessageBase: &gen.ProtoMessageBase{
|
||||
PayloadType: payloadType,
|
||||
},
|
||||
}
|
||||
|
||||
if opts != nil {
|
||||
if opts.Latency != nil {
|
||||
msg.MessageBase.Latency = opts.Latency
|
||||
} else if opts.SequenceID != "" {
|
||||
msg.MessageBase.Latency = &gen.ProtoLatencyTracker{
|
||||
SequenceId: opts.SequenceID,
|
||||
Timestamps: []*gen.ProtoTimestampEntry{
|
||||
{
|
||||
Stage: "created",
|
||||
Time: timestamppb.Now(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use reflection to set the oneof field automatically
|
||||
msgReflect := msg.ProtoReflect()
|
||||
payloadReflect := payload.ProtoReflect()
|
||||
|
||||
oneofDesc := msgReflect.Descriptor().Oneofs().ByName("payload")
|
||||
if oneofDesc == nil {
|
||||
return nil, errors.New("payload oneof not found")
|
||||
}
|
||||
|
||||
fields := oneofDesc.Fields()
|
||||
for i := 0; i < fields.Len(); i++ {
|
||||
field := fields.Get(i)
|
||||
if field.Message() != nil && field.Message().FullName() == payloadReflect.Descriptor().FullName() {
|
||||
msgReflect.Set(field, protoreflect.ValueOfMessage(payloadReflect))
|
||||
return msg, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("payload type not found in oneof")
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SafeMap is a generic thread-safe map with its own mutex
|
||||
type SafeMap[K comparable, V any] struct {
|
||||
mu sync.RWMutex
|
||||
m map[K]V
|
||||
}
|
||||
|
||||
// NewSafeMap creates a new SafeMap instance
|
||||
func NewSafeMap[K comparable, V any]() *SafeMap[K, V] {
|
||||
return &SafeMap[K, V]{
|
||||
m: make(map[K]V),
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves a value from the map
|
||||
func (sm *SafeMap[K, V]) Get(key K) (V, bool) {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
v, ok := sm.m[key]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// Has checks if a key exists in the map
|
||||
func (sm *SafeMap[K, V]) Has(key K) bool {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
_, ok := sm.m[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Set adds or updates a value in the map
|
||||
func (sm *SafeMap[K, V]) Set(key K, value V) {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
sm.m[key] = value
|
||||
}
|
||||
|
||||
// Delete removes a key from the map
|
||||
func (sm *SafeMap[K, V]) Delete(key K) {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
delete(sm.m, key)
|
||||
}
|
||||
|
||||
// Len returns the number of items in the map
|
||||
func (sm *SafeMap[K, V]) Len() int {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
return len(sm.m)
|
||||
}
|
||||
|
||||
// Copy creates a shallow copy of the map and returns it
|
||||
func (sm *SafeMap[K, V]) Copy() map[K]V {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
copied := make(map[K]V, len(sm.m))
|
||||
for k, v := range sm.m {
|
||||
copied[k] = v
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
// Range iterates over the map and applies a function to each key-value pair
|
||||
func (sm *SafeMap[K, V]) Range(f func(K, V) bool) {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
for k, v := range sm.m {
|
||||
if !f(k, v) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *SafeMap[K, V]) MarshalJSON() ([]byte, error) {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
return json.Marshal(sm.m)
|
||||
}
|
||||
|
||||
func (sm *SafeMap[K, V]) UnmarshalJSON(data []byte) error {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
return json.Unmarshal(data, &sm.m)
|
||||
}
|
||||
|
||||
func (sm *SafeMap[K, V]) String() string {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
return fmt.Sprintf("%+v", sm.m)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package connections
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
gen "relay/internal/proto"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type OnMessageCallback func(data []byte)
|
||||
|
||||
// NestriDataChannel is a custom data channel with callbacks
|
||||
type NestriDataChannel struct {
|
||||
*webrtc.DataChannel
|
||||
callbacks map[string]OnMessageCallback // MessageBase type -> callback
|
||||
}
|
||||
|
||||
// NewNestriDataChannel creates a new NestriDataChannel from *webrtc.DataChannel
|
||||
func NewNestriDataChannel(dc *webrtc.DataChannel) *NestriDataChannel {
|
||||
ndc := &NestriDataChannel{
|
||||
DataChannel: dc,
|
||||
callbacks: make(map[string]OnMessageCallback),
|
||||
}
|
||||
|
||||
// Handler for incoming messages
|
||||
ndc.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
// If string type message, ignore
|
||||
if msg.IsString {
|
||||
return
|
||||
}
|
||||
|
||||
// Decode message
|
||||
var base gen.ProtoMessage
|
||||
if err := proto.Unmarshal(msg.Data, &base); err != nil {
|
||||
slog.Error("failed to decode binary DataChannel message", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Route based on PayloadType
|
||||
if base.MessageBase != nil && len(base.MessageBase.PayloadType) > 0 {
|
||||
if callback, ok := ndc.callbacks[base.MessageBase.PayloadType]; ok {
|
||||
go callback(msg.Data)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return ndc
|
||||
}
|
||||
|
||||
// SendBinary sends a binary message to the data channel
|
||||
func (ndc *NestriDataChannel) SendBinary(data []byte) error {
|
||||
return ndc.Send(data)
|
||||
}
|
||||
|
||||
// RegisterMessageCallback registers a callback for a given binary message type
|
||||
func (ndc *NestriDataChannel) RegisterMessageCallback(msgType string, callback OnMessageCallback) {
|
||||
if ndc.callbacks == nil {
|
||||
ndc.callbacks = make(map[string]OnMessageCallback)
|
||||
}
|
||||
ndc.callbacks[msgType] = callback
|
||||
}
|
||||
|
||||
// UnregisterMessageCallback removes the callback for a given binary message type
|
||||
func (ndc *NestriDataChannel) UnregisterMessageCallback(msgType string) {
|
||||
if ndc.callbacks != nil {
|
||||
delete(ndc.callbacks, msgType)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterOnOpen registers a callback for the data channel opening
|
||||
func (ndc *NestriDataChannel) RegisterOnOpen(callback func()) {
|
||||
ndc.OnOpen(callback)
|
||||
}
|
||||
|
||||
// RegisterOnClose registers a callback for the data channel closing
|
||||
func (ndc *NestriDataChannel) RegisterOnClose(callback func()) {
|
||||
ndc.OnClose(callback)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package core
|
||||
|
||||
import "time"
|
||||
|
||||
// --- Constants ---
|
||||
const (
|
||||
// PubSub Topics
|
||||
roomStateTopicName = "room-states"
|
||||
relayMetricsTopicName = "relay-metrics"
|
||||
|
||||
// Timers and Intervals
|
||||
metricsPublishInterval = 15 * time.Second // How often to publish own metrics
|
||||
)
|
||||
@@ -1,256 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"relay/internal/common"
|
||||
"relay/internal/shared"
|
||||
|
||||
"github.com/libp2p/go-libp2p"
|
||||
pubsub "github.com/libp2p/go-libp2p-pubsub"
|
||||
"github.com/libp2p/go-libp2p/core/crypto"
|
||||
"github.com/libp2p/go-libp2p/core/host"
|
||||
"github.com/libp2p/go-libp2p/core/network"
|
||||
"github.com/libp2p/go-libp2p/core/peer"
|
||||
rcmgr "github.com/libp2p/go-libp2p/p2p/host/resource-manager"
|
||||
"github.com/libp2p/go-libp2p/p2p/protocol/ping"
|
||||
"github.com/libp2p/go-libp2p/p2p/security/noise"
|
||||
p2pquic "github.com/libp2p/go-libp2p/p2p/transport/quic"
|
||||
"github.com/libp2p/go-libp2p/p2p/transport/quicreuse"
|
||||
"github.com/libp2p/go-libp2p/p2p/transport/tcp"
|
||||
webtransport "github.com/libp2p/go-libp2p/p2p/transport/webtransport"
|
||||
"github.com/multiformats/go-multiaddr"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
// -- Variables --
|
||||
|
||||
var globalRelay *Relay
|
||||
|
||||
// -- Structs --
|
||||
|
||||
// Relay structure enhanced with metrics and state
|
||||
type Relay struct {
|
||||
*PeerInfo
|
||||
|
||||
Host host.Host // libp2p host for peer-to-peer networking
|
||||
PubSub *pubsub.PubSub // PubSub for state synchronization
|
||||
PingService *ping.PingService
|
||||
|
||||
// Local
|
||||
LocalRooms *common.SafeMap[ulid.ULID, *shared.Room] // room ID -> local Room struct (hosted by this relay)
|
||||
LocalMeshConnections *common.SafeMap[peer.ID, *webrtc.PeerConnection] // peer ID -> PeerConnection (connected to this relay)
|
||||
|
||||
// Protocols
|
||||
ProtocolRegistry
|
||||
|
||||
// PubSub Topics
|
||||
pubTopicState *pubsub.Topic // topic for room states
|
||||
pubTopicRelayMetrics *pubsub.Topic // topic for relay metrics/status
|
||||
}
|
||||
|
||||
func NewRelay(ctx context.Context, port int, identityKey crypto.PrivKey) (*Relay, error) {
|
||||
// If metrics are enabled, start the metrics server first
|
||||
metricsOpts := make([]libp2p.Option, 0)
|
||||
var rmgr network.ResourceManager
|
||||
if common.GetFlags().Metrics {
|
||||
go func() {
|
||||
slog.Info("Starting prometheus metrics server at '/debug/metrics/prometheus'", "port", common.GetFlags().MetricsPort)
|
||||
http.Handle("/debug/metrics/prometheus", promhttp.Handler())
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%d", common.GetFlags().MetricsPort), nil); err != nil {
|
||||
slog.Error("Failed to start metrics server", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
rcmgr.MustRegisterWith(prometheus.DefaultRegisterer)
|
||||
|
||||
str, err := rcmgr.NewStatsTraceReporter()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
rmgr, err = rcmgr.NewResourceManager(rcmgr.NewFixedLimiter(rcmgr.DefaultLimits.AutoScale()), rcmgr.WithTraceReporter(str))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
metricsOpts = append(metricsOpts, libp2p.ResourceManager(rmgr))
|
||||
metricsOpts = append(metricsOpts, libp2p.PrometheusRegisterer(prometheus.DefaultRegisterer))
|
||||
} else {
|
||||
rmgr = nil
|
||||
}
|
||||
|
||||
listenAddrs := []string{
|
||||
fmt.Sprintf("/ip4/0.0.0.0/tcp/%d", port), // IPv4 - Raw TCP
|
||||
fmt.Sprintf("/ip6/::/tcp/%d", port), // IPv6 - Raw TCP
|
||||
fmt.Sprintf("/ip4/0.0.0.0/udp/%d/quic-v1/webtransport", port), // IPv4 - UDP QUIC WebTransport
|
||||
fmt.Sprintf("/ip6/::/udp/%d/quic-v1/webtransport", port), // IPv6 - UDP QUIC WebTransport
|
||||
fmt.Sprintf("/ip4/0.0.0.0/udp/%d/quic-v1", port), // IPv4 - UDP Raw QUIC
|
||||
fmt.Sprintf("/ip6/::/udp/%d/quic-v1", port), // IPv6 - UDP Raw QUIC
|
||||
}
|
||||
|
||||
var muAddrs []multiaddr.Multiaddr
|
||||
for _, addr := range listenAddrs {
|
||||
multiAddr, err := multiaddr.NewMultiaddr(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse multiaddr '%s': %w", addr, err)
|
||||
}
|
||||
muAddrs = append(muAddrs, multiAddr)
|
||||
}
|
||||
|
||||
// Initialize libp2p host
|
||||
p2pHost, err := libp2p.New(
|
||||
libp2p.ChainOptions(metricsOpts...),
|
||||
libp2p.Identity(identityKey),
|
||||
// Enable required transports
|
||||
libp2p.Transport(tcp.NewTCPTransport),
|
||||
libp2p.Transport(webtransport.New),
|
||||
libp2p.Transport(p2pquic.NewTransport),
|
||||
// Other options
|
||||
libp2p.ListenAddrs(muAddrs...),
|
||||
libp2p.Security(noise.ID, noise.New),
|
||||
libp2p.EnableRelay(),
|
||||
libp2p.EnableHolePunching(),
|
||||
libp2p.EnableNATService(),
|
||||
libp2p.EnableAutoNATv2(),
|
||||
libp2p.ShareTCPListener(),
|
||||
libp2p.QUICReuse(quicreuse.NewConnManager),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create libp2p host for relay: %w", err)
|
||||
}
|
||||
|
||||
// Set up pubsub
|
||||
p2pPubsub, err := pubsub.NewGossipSub(ctx, p2pHost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create pubsub: %w, addrs: %v", err, p2pHost.Addrs())
|
||||
}
|
||||
|
||||
// Initialize Ping Service
|
||||
pingSvc := ping.NewPingService(p2pHost)
|
||||
|
||||
r := &Relay{
|
||||
PeerInfo: NewPeerInfo(p2pHost.ID(), p2pHost.Addrs()),
|
||||
Host: p2pHost,
|
||||
PubSub: p2pPubsub,
|
||||
PingService: pingSvc,
|
||||
LocalRooms: common.NewSafeMap[ulid.ULID, *shared.Room](),
|
||||
LocalMeshConnections: common.NewSafeMap[peer.ID, *webrtc.PeerConnection](),
|
||||
}
|
||||
|
||||
// Add network notifier after relay is initialized
|
||||
p2pHost.Network().Notify(&networkNotifier{relay: r})
|
||||
|
||||
// Set up PubSub topics and handlers
|
||||
if err = r.setupPubSub(ctx); err != nil {
|
||||
err = p2pHost.Close()
|
||||
if err != nil {
|
||||
slog.Error("Failed to close host after PubSub setup failure", "err", err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to setup PubSub: %w", err)
|
||||
}
|
||||
|
||||
// Initialize Protocol Registry
|
||||
r.ProtocolRegistry = NewProtocolRegistry(r)
|
||||
|
||||
// Start discovery features
|
||||
if err = startMDNSDiscovery(r); err != nil {
|
||||
slog.Warn("Failed to initialize mDNS discovery, continuing without..", "error", err)
|
||||
}
|
||||
|
||||
// Start background tasks
|
||||
go r.periodicMetricsPublisher(ctx)
|
||||
|
||||
printConnectInstructions(p2pHost)
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func InitRelay(ctx context.Context, ctxCancel context.CancelFunc) (*Relay, error) {
|
||||
var err error
|
||||
persistentDir := common.GetFlags().PersistDir
|
||||
|
||||
// Load or generate identity key
|
||||
var identityKey crypto.PrivKey
|
||||
var privKey ed25519.PrivateKey
|
||||
// First check if we need to generate identity
|
||||
hasIdentity := len(persistentDir) > 0 && common.GetFlags().RegenIdentity == false
|
||||
if hasIdentity {
|
||||
_, err = os.Stat(persistentDir + "/identity.key")
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("failed to check identity key file: %w", err)
|
||||
} else if os.IsNotExist(err) {
|
||||
hasIdentity = false
|
||||
}
|
||||
}
|
||||
if !hasIdentity {
|
||||
// Make sure the persistent directory exists
|
||||
if err = os.MkdirAll(persistentDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create persistent data directory: %w", err)
|
||||
}
|
||||
// Generate
|
||||
slog.Info("Generating new identity for relay")
|
||||
privKey, err = common.GenerateED25519Key()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate new identity: %w", err)
|
||||
}
|
||||
// Save the key
|
||||
if err = common.SaveED25519Key(privKey, persistentDir+"/identity.key"); err != nil {
|
||||
return nil, fmt.Errorf("failed to save identity key: %w", err)
|
||||
}
|
||||
slog.Info("New identity generated and saved", "path", persistentDir+"/identity.key")
|
||||
} else {
|
||||
slog.Info("Loading existing identity for relay", "path", persistentDir+"/identity.key")
|
||||
// Load the key
|
||||
privKey, err = common.LoadED25519Key(persistentDir + "/identity.key")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load identity key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to libp2p crypto.PrivKey
|
||||
identityKey, err = crypto.UnmarshalEd25519PrivateKey(privKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal ED25519 private key: %w", err)
|
||||
}
|
||||
|
||||
globalRelay, err = NewRelay(ctx, common.GetFlags().EndpointPort, identityKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create relay: %w", err)
|
||||
}
|
||||
|
||||
if err = common.InitWebRTCAPI(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slog.Info("Relay initialized", "id", globalRelay.ID)
|
||||
|
||||
// Load previous peers on startup
|
||||
defaultFile := common.GetFlags().PersistDir + "/peerstore.json"
|
||||
if err = globalRelay.LoadFromFile(defaultFile); err != nil {
|
||||
slog.Warn("Failed to load previous peer store", "error", err)
|
||||
} else {
|
||||
globalRelay.Peers.Range(func(id peer.ID, pi *PeerInfo) bool {
|
||||
if len(pi.Addrs) <= 0 {
|
||||
slog.Warn("Peer from peer store has no addresses", "peer", id)
|
||||
return true
|
||||
}
|
||||
|
||||
// Connect to first address only
|
||||
if err = globalRelay.ConnectToPeer(context.Background(), pi.Addrs[0]); err != nil {
|
||||
slog.Error("Failed to connect to peer from peer store", "peer", id, "error", err)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return globalRelay, nil
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/libp2p/go-libp2p/core/peer"
|
||||
"github.com/libp2p/go-libp2p/p2p/discovery/mdns"
|
||||
)
|
||||
|
||||
const (
|
||||
mdnsDiscoveryRendezvous = "/nestri-relay/mdns-discovery/1.0.0" // Shared string for mDNS discovery
|
||||
)
|
||||
|
||||
type discoveryNotifee struct {
|
||||
relay *Relay
|
||||
}
|
||||
|
||||
func (d *discoveryNotifee) HandlePeerFound(pi peer.AddrInfo) {
|
||||
if d.relay != nil {
|
||||
if err := d.relay.connectToPeer(context.Background(), &pi); err != nil {
|
||||
slog.Error("failed to connect to discovered relay", "peer", pi.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startMDNSDiscovery(relay *Relay) error {
|
||||
d := &discoveryNotifee{
|
||||
relay: relay,
|
||||
}
|
||||
|
||||
service := mdns.NewMdnsService(relay.Host, mdnsDiscoveryRendezvous, d)
|
||||
if err := service.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start mDNS discovery: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/libp2p/go-libp2p/core/peer"
|
||||
)
|
||||
|
||||
// --- Metrics Collection and Publishing ---
|
||||
|
||||
// periodicMetricsPublisher periodically gathers local metrics and publishes them.
|
||||
func (r *Relay) periodicMetricsPublisher(ctx context.Context) {
|
||||
ticker := time.NewTicker(metricsPublishInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Publish immediately on start
|
||||
if err := r.publishRelayMetrics(ctx); err != nil {
|
||||
slog.Error("Failed to publish initial relay metrics", "err", err)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.Info("Stopping metrics publisher")
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := r.publishRelayMetrics(ctx); err != nil {
|
||||
slog.Error("Failed to publish relay metrics", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// publishRelayMetrics sends the current relay status to the mesh.
|
||||
func (r *Relay) publishRelayMetrics(ctx context.Context) error {
|
||||
if r.pubTopicRelayMetrics == nil {
|
||||
slog.Warn("Cannot publish relay metrics: topic is nil")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check all peer latencies
|
||||
r.checkAllPeerLatencies(ctx)
|
||||
|
||||
data, err := json.Marshal(r.PeerInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal relay status: %w", err)
|
||||
}
|
||||
|
||||
if pubErr := r.pubTopicRelayMetrics.Publish(ctx, data); pubErr != nil {
|
||||
// Don't return error on publish failure, just log
|
||||
slog.Error("Failed to publish relay metrics message", "err", pubErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkAllPeerLatencies measures latency to all currently connected peers.
|
||||
func (r *Relay) checkAllPeerLatencies(ctx context.Context) {
|
||||
var wg sync.WaitGroup
|
||||
for _, p := range r.Host.Network().Peers() {
|
||||
if p == r.ID {
|
||||
continue // Skip self
|
||||
}
|
||||
wg.Add(1)
|
||||
// Run checks concurrently
|
||||
go func(peerID peer.ID) {
|
||||
defer wg.Done()
|
||||
go r.measureLatencyToPeer(ctx, peerID)
|
||||
}(p)
|
||||
}
|
||||
wg.Wait() // Wait for all latency checks to complete
|
||||
}
|
||||
|
||||
// measureLatencyToPeer pings a specific peer and updates the local latency map.
|
||||
func (r *Relay) measureLatencyToPeer(ctx context.Context, peerID peer.ID) {
|
||||
// Check peer status first
|
||||
if !r.hasConnectedPeer(peerID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a context for the ping operation
|
||||
pingCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Use the PingService instance stored in the Relay struct
|
||||
if r.PingService == nil {
|
||||
slog.Error("PingService is nil, cannot measure latency", "peer", peerID)
|
||||
return
|
||||
}
|
||||
resultsCh := r.PingService.Ping(pingCtx, peerID)
|
||||
|
||||
// Wait for the result (or timeout)
|
||||
select {
|
||||
case <-pingCtx.Done():
|
||||
// Ping timed out
|
||||
slog.Warn("Latency check canceled", "peer", peerID, "err", pingCtx.Err())
|
||||
case result, ok := <-resultsCh:
|
||||
if !ok {
|
||||
// Channel closed unexpectedly
|
||||
slog.Warn("Ping service channel closed unexpectedly", "peer", peerID)
|
||||
return
|
||||
}
|
||||
|
||||
// Received ping result
|
||||
if result.Error != nil {
|
||||
slog.Warn("Latency check failed, removing peer from local peers map", "peer", peerID, "err", result.Error)
|
||||
// Remove from MeshPeers if ping failed
|
||||
if r.Peers.Has(peerID) {
|
||||
r.Peers.Delete(peerID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Ping successful, update latency
|
||||
latency := result.RTT
|
||||
// Ensure latency is not zero if successful, assign a minimal value if so.
|
||||
// Sometimes RTT can be reported as 0 for very fast local connections.
|
||||
if latency <= 0 {
|
||||
latency = 1 * time.Microsecond
|
||||
}
|
||||
|
||||
r.PeerInfo.Latencies.Set(peerID, latency)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user