forked from Kieran/zap.stream
eject/wish player
This commit is contained in:
parent
05bf4cbfa6
commit
cbc49a0def
36
package.json
36
package.json
@ -12,6 +12,7 @@
|
|||||||
"@testing-library/user-event": "^13.2.1",
|
"@testing-library/user-event": "^13.2.1",
|
||||||
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
|
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
|
||||||
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
|
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"hls.js": "^1.4.6",
|
"hls.js": "^1.4.6",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
@ -20,14 +21,13 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-intersection-observer": "^9.5.1",
|
"react-intersection-observer": "^9.5.1",
|
||||||
"react-router-dom": "^6.13.0",
|
"react-router-dom": "^6.13.0",
|
||||||
"react-scripts": "5.0.1",
|
"semantic-sdp": "^3.26.2",
|
||||||
"web-vitals": "^2.1.0"
|
"web-vitals": "^2.1.0",
|
||||||
|
"webrtc-adapter": "^8.2.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "webpack serve",
|
||||||
"build": "react-scripts build",
|
"build": "webpack --node-env=production",
|
||||||
"test": "react-scripts test",
|
|
||||||
"eject": "react-scripts eject",
|
|
||||||
"deploy": "npx wrangler pages publish build"
|
"deploy": "npx wrangler pages publish build"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
@ -49,10 +49,30 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
"@babel/plugin-syntax-import-assertions": "^7.20.0",
|
||||||
|
"@babel/preset-env": "^7.21.5",
|
||||||
|
"@babel/preset-react": "^7.18.6",
|
||||||
|
"@formatjs/cli": "^6.0.1",
|
||||||
|
"@formatjs/ts-transformer": "^3.13.1",
|
||||||
"@types/lodash": "^4.14.195",
|
"@types/lodash": "^4.14.195",
|
||||||
"@webbtc/webln-types": "^1.0.12",
|
"@webbtc/webln-types": "^1.0.12",
|
||||||
|
"babel-loader": "^9.1.2",
|
||||||
|
"babel-plugin-formatjs": "^10.5.1",
|
||||||
|
"copy-webpack-plugin": "^11.0.0",
|
||||||
|
"css-loader": "^6.7.3",
|
||||||
|
"css-minimizer-webpack-plugin": "^5.0.0",
|
||||||
|
"eslint": "^8.43.0",
|
||||||
|
"eslint-plugin-formatjs": "^4.10.1",
|
||||||
|
"eslint-webpack-plugin": "^4.0.1",
|
||||||
|
"html-webpack-plugin": "^5.5.1",
|
||||||
|
"mini-css-extract-plugin": "^2.7.5",
|
||||||
"prettier": "^2.8.8",
|
"prettier": "^2.8.8",
|
||||||
"typescript": "^5.1.3"
|
"ts-loader": "^9.4.2",
|
||||||
|
"typescript": "^5.1.3",
|
||||||
|
"webpack": "^5.82.1",
|
||||||
|
"webpack-bundle-analyzer": "^4.8.0",
|
||||||
|
"webpack-cli": "^5.1.1",
|
||||||
|
"webpack-dev-server": "^4.15.0",
|
||||||
|
"workbox-webpack-plugin": "^6.5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,12 +3,12 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta name="description" content="Nostr live streaming" />
|
<meta name="description" content="Nostr live streaming" />
|
||||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<title>Nostr stream</title>
|
<title>Nostr stream</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import Hls from "hls.js";
|
import Hls from "hls.js";
|
||||||
import { HTMLProps, useEffect, useMemo, useRef, useState } from "react";
|
import { HTMLProps, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { WISH } from "wish";
|
||||||
|
|
||||||
export enum VideoStatus {
|
export enum VideoStatus {
|
||||||
Online = "online",
|
Online = "online",
|
||||||
@ -52,3 +53,34 @@ export function LiveVideoPlayer(
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function WebRTCPlayer(props: HTMLProps<HTMLVideoElement> & { stream?: string }) {
|
||||||
|
const video = useRef<HTMLVideoElement>(null);
|
||||||
|
const streamCached = useMemo(() => "https://customer-uu10flpvos4pfhgu.cloudflarestream.com/7634aee1af35a2de4ac13ca3d1718a8b/webRTC/play", [props.stream]);
|
||||||
|
const [status, setStatus] = useState<VideoStatus>();
|
||||||
|
//https://customer-uu10flpvos4pfhgu.cloudflarestream.com/7634aee1af35a2de4ac13ca3d1718a8b/webRTC/play
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (video.current && streamCached) {
|
||||||
|
const client = new WISH();
|
||||||
|
client.addEventListener("log", console.debug);
|
||||||
|
client.WithEndpoint(streamCached, true)
|
||||||
|
|
||||||
|
client.Play().then(s => {
|
||||||
|
if (video.current) {
|
||||||
|
video.current.srcObject = s;
|
||||||
|
}
|
||||||
|
}).catch(console.error);
|
||||||
|
return () => { client.Disconnect().catch(console.error); }
|
||||||
|
}
|
||||||
|
}, [video, streamCached]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={status}>
|
||||||
|
<div>{status}</div>
|
||||||
|
</div>
|
||||||
|
<video ref={video} {...props} controls={status === VideoStatus.Online} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
32
src/service-worker.ts
Normal file
32
src/service-worker.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
import {} from ".";
|
||||||
|
declare const self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
|
import { clientsClaim } from "workbox-core";
|
||||||
|
import { registerRoute } from "workbox-routing";
|
||||||
|
import { CacheFirst } from "workbox-strategies";
|
||||||
|
|
||||||
|
clientsClaim();
|
||||||
|
|
||||||
|
const staticTypes = ["image", "video", "audio", "script", "style", "font"];
|
||||||
|
registerRoute(
|
||||||
|
({ request, url }) => url.origin === self.location.origin && staticTypes.includes(request.destination),
|
||||||
|
new CacheFirst({
|
||||||
|
cacheName: "static-content",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// External media domains which have unique urls (never changing content) and can be cached forever
|
||||||
|
const externalMediaHosts = ["void.cat", "nostr.build", "imgur.com", "i.imgur.com", "pbs.twimg.com", "i.ibb.co"];
|
||||||
|
registerRoute(
|
||||||
|
({ url }) => externalMediaHosts.includes(url.host),
|
||||||
|
new CacheFirst({
|
||||||
|
cacheName: "ext-content-hosts",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
self.addEventListener("message", event => {
|
||||||
|
if (event.data && event.data.type === "SKIP_WAITING") {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
});
|
32
src/wish/events.ts
Normal file
32
src/wish/events.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
interface StateEventMap {
|
||||||
|
log: CustomEvent<LogEvent>;
|
||||||
|
status: CustomEvent<StatusEvent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateEventTarget extends EventTarget {
|
||||||
|
addEventListener<K extends keyof StateEventMap>(
|
||||||
|
type: K,
|
||||||
|
listener: (ev: StateEventMap[K]) => void,
|
||||||
|
options?: boolean | AddEventListenerOptions
|
||||||
|
): void;
|
||||||
|
addEventListener(
|
||||||
|
type: string,
|
||||||
|
callback: EventListenerOrEventListenerObject | null,
|
||||||
|
options?: EventListenerOptions | boolean
|
||||||
|
): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TypedEventTarget = EventTarget as {
|
||||||
|
new (): StateEventTarget;
|
||||||
|
prototype: StateEventTarget;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LogEvent {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusEvent {
|
||||||
|
status: Status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Status = "connected" | "disconnected";
|
675
src/wish/index.ts
Normal file
675
src/wish/index.ts
Normal file
@ -0,0 +1,675 @@
|
|||||||
|
import adapter from "webrtc-adapter";
|
||||||
|
import { CandidateInfo, SDPInfo } from "semantic-sdp";
|
||||||
|
import { TypedEventTarget, type StatusEvent, type LogEvent } from "./events";
|
||||||
|
import { parserLinkHeader } from "./parser";
|
||||||
|
|
||||||
|
export const DEFAULT_ICE_SERVERS = [
|
||||||
|
"stun:stun.cloudflare.com:3478",
|
||||||
|
"stun:stun.l.google.com:19302",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TRICKLE_BATCH_INTERVAL = 50;
|
||||||
|
|
||||||
|
enum Mode {
|
||||||
|
Player = "player",
|
||||||
|
Publisher = "publisher",
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WISH extends TypedEventTarget {
|
||||||
|
private peerConnection?: RTCPeerConnection;
|
||||||
|
private iceServers: string[] = DEFAULT_ICE_SERVERS;
|
||||||
|
|
||||||
|
private videoSender?: RTCRtpSender;
|
||||||
|
|
||||||
|
private remoteTracks: MediaStreamTrack[] = [];
|
||||||
|
private playerMedia?: MediaStream;
|
||||||
|
|
||||||
|
private connecting: boolean = false;
|
||||||
|
private connectedPromise!: Promise<void>;
|
||||||
|
private connectedResolver!: (any: void) => void;
|
||||||
|
private connectedRejector!: (reason?: any) => void;
|
||||||
|
private gatherPromise!: Promise<void>;
|
||||||
|
private gatherResolver!: (any: void) => void;
|
||||||
|
|
||||||
|
private endpoint?: string;
|
||||||
|
private resourceURL?: string;
|
||||||
|
private mode: Mode = Mode.Player;
|
||||||
|
private parsedOffer?: SDPInfo;
|
||||||
|
private useTrickle: boolean = false;
|
||||||
|
private etag?: string;
|
||||||
|
|
||||||
|
private trickleBatchingJob?: ReturnType<typeof setInterval>;
|
||||||
|
private batchedCandidates: RTCIceCandidate[] = [];
|
||||||
|
|
||||||
|
private connectStartTime?: number;
|
||||||
|
private iceStartTime?: number;
|
||||||
|
|
||||||
|
constructor(iceServers?: string[]) {
|
||||||
|
super();
|
||||||
|
if (iceServers) {
|
||||||
|
this.iceServers = iceServers ? iceServers : DEFAULT_ICE_SERVERS;
|
||||||
|
}
|
||||||
|
this.logMessage(
|
||||||
|
`Enabling webrtc-adapter for ${adapter.browserDetails.browser}@${adapter.browserDetails.version}`
|
||||||
|
);
|
||||||
|
this.newResolvers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private logMessage(str: string) {
|
||||||
|
const now = new Date().toLocaleString();
|
||||||
|
console.log(`${now}: ${str}`);
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent<LogEvent>("log", {
|
||||||
|
detail: {
|
||||||
|
message: str,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private killConnection() {
|
||||||
|
if (this.peerConnection) {
|
||||||
|
this.logMessage("Closing RTCPeerConnection");
|
||||||
|
this.peerConnection.close();
|
||||||
|
this.peerConnection = undefined;
|
||||||
|
this.parsedOffer = undefined;
|
||||||
|
this.playerMedia = undefined;
|
||||||
|
this.videoSender = undefined;
|
||||||
|
this.connecting = false;
|
||||||
|
this.remoteTracks = [];
|
||||||
|
this.batchedCandidates = [];
|
||||||
|
this.stopTrickleBatching();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createConnection() {
|
||||||
|
this.logMessage("Creating a new RTCPeerConnection");
|
||||||
|
this.peerConnection = new RTCPeerConnection({
|
||||||
|
iceServers: [{ urls: this.iceServers }],
|
||||||
|
});
|
||||||
|
if (!this.peerConnection) {
|
||||||
|
throw new Error("Failed to create a new RTCPeerConnection");
|
||||||
|
}
|
||||||
|
this.addEventListeners();
|
||||||
|
this.newResolvers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private newResolvers() {
|
||||||
|
this.connectedPromise = new Promise((resolve, reject) => {
|
||||||
|
this.connectedResolver = resolve;
|
||||||
|
this.connectedRejector = reject;
|
||||||
|
});
|
||||||
|
this.gatherPromise = new Promise((resolve) => {
|
||||||
|
this.gatherResolver = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private addEventListeners() {
|
||||||
|
if (!this.peerConnection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.peerConnection.addEventListener(
|
||||||
|
"connectionstatechange",
|
||||||
|
this.onConnectionStateChange.bind(this)
|
||||||
|
);
|
||||||
|
this.peerConnection.addEventListener(
|
||||||
|
"iceconnectionstatechange",
|
||||||
|
this.onICEConnectionStateChange.bind(this)
|
||||||
|
);
|
||||||
|
this.peerConnection.addEventListener(
|
||||||
|
"icegatheringstatechange",
|
||||||
|
this.onGatheringStateChange.bind(this)
|
||||||
|
);
|
||||||
|
this.peerConnection.addEventListener(
|
||||||
|
"icecandidate",
|
||||||
|
this.onICECandidate.bind(this)
|
||||||
|
);
|
||||||
|
this.peerConnection.addEventListener("track", this.onTrack.bind(this));
|
||||||
|
this.peerConnection.addEventListener(
|
||||||
|
"signalingstatechange",
|
||||||
|
this.onSignalingStateChange.bind(this)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onGatheringStateChange() {
|
||||||
|
if (!this.peerConnection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logMessage(
|
||||||
|
`ICE Gathering State changed: ${this.peerConnection.iceGatheringState}`
|
||||||
|
);
|
||||||
|
switch (this.peerConnection.iceGatheringState) {
|
||||||
|
case "complete":
|
||||||
|
this.gatherResolver();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onConnectionStateChange() {
|
||||||
|
if (!this.peerConnection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logMessage(
|
||||||
|
`Peer Connection State changed: ${this.peerConnection.connectionState}`
|
||||||
|
);
|
||||||
|
const transportHandler = (
|
||||||
|
track: MediaStreamTrack,
|
||||||
|
transport: RTCDtlsTransport
|
||||||
|
) => {
|
||||||
|
const ice = transport.iceTransport;
|
||||||
|
if (!ice) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pair = ice.getSelectedCandidatePair();
|
||||||
|
if (!pair) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pair.local && pair.remote) {
|
||||||
|
this.logMessage(
|
||||||
|
`[${track.kind}] Selected Candidate: (local ${pair.local.address})-(remote ${pair.remote.candidate})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
switch (this.peerConnection.connectionState) {
|
||||||
|
case "connected":
|
||||||
|
switch (this.mode) {
|
||||||
|
case Mode.Player:
|
||||||
|
for (const receiver of this.peerConnection.getReceivers()) {
|
||||||
|
const transport = receiver.transport;
|
||||||
|
if (!transport) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
transportHandler(receiver.track, transport);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case Mode.Publisher:
|
||||||
|
for (const sender of this.peerConnection.getSenders()) {
|
||||||
|
const transport = sender.transport;
|
||||||
|
if (!transport) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!sender.track) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (sender.track.kind === "video") {
|
||||||
|
this.videoSender = sender;
|
||||||
|
}
|
||||||
|
transportHandler(sender.track, transport);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "failed":
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent<StatusEvent>("status", {
|
||||||
|
detail: {
|
||||||
|
status: "disconnected",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onICECandidate(ev: RTCPeerConnectionIceEvent) {
|
||||||
|
if (ev.candidate) {
|
||||||
|
const candidate = ev.candidate;
|
||||||
|
if (!candidate.candidate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logMessage(
|
||||||
|
`Got ICE candidate: ${candidate.candidate.replace("candidate:", "")}`
|
||||||
|
);
|
||||||
|
if (!this.parsedOffer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.useTrickle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (candidate.candidate.includes(".local")) {
|
||||||
|
this.logMessage("Skipping mDNS candidate for trickle ICE");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.batchedCandidates.push(candidate);
|
||||||
|
} else {
|
||||||
|
this.logMessage(`End of ICE candidates`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startTrickleBatching() {
|
||||||
|
if (this.trickleBatchingJob) {
|
||||||
|
clearInterval(this.trickleBatchingJob);
|
||||||
|
}
|
||||||
|
this.logMessage(
|
||||||
|
`Starting batching job to trickle candidates every ${TRICKLE_BATCH_INTERVAL}ms`
|
||||||
|
);
|
||||||
|
this.trickleBatchingJob = setInterval(
|
||||||
|
this.trickleBatch.bind(this),
|
||||||
|
TRICKLE_BATCH_INTERVAL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopTrickleBatching() {
|
||||||
|
if (!this.trickleBatchingJob) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logMessage("Stopping trickle batching job");
|
||||||
|
clearInterval(this.trickleBatchingJob);
|
||||||
|
this.trickleBatchingJob = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async trickleBatch() {
|
||||||
|
if (!this.parsedOffer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.batchedCandidates.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragSDP = new SDPInfo();
|
||||||
|
const candidates = this.batchedCandidates.splice(0);
|
||||||
|
this.logMessage(`Tricking with ${candidates.length} candidates`);
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const candidateObject = CandidateInfo.expand({
|
||||||
|
foundation: candidate.foundation || "",
|
||||||
|
componentId: candidate.component === "rtp" ? 1 : 2,
|
||||||
|
transport: candidate.protocol || "udp",
|
||||||
|
priority: candidate.priority || 0,
|
||||||
|
address: candidate.address || "",
|
||||||
|
port: candidate.port || 0,
|
||||||
|
type: candidate.type || "host",
|
||||||
|
relAddr: candidate.relatedAddress || undefined,
|
||||||
|
relPort:
|
||||||
|
typeof candidate.relatedPort !== "undefined" &&
|
||||||
|
candidate.relatedPort !== null
|
||||||
|
? candidate.relatedPort.toString()
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
fragSDP.addCandidate(candidateObject);
|
||||||
|
}
|
||||||
|
fragSDP.setICE(this.parsedOffer.getICE());
|
||||||
|
|
||||||
|
const generated = fragSDP.toIceFragmentString();
|
||||||
|
// for trickle-ice-sdpfrag, we need a psuedo m= line
|
||||||
|
const lines = generated.split(/\r?\n/);
|
||||||
|
lines.splice(2, 0, "m=audio 9 RTP/AVP 0");
|
||||||
|
lines.splice(3, 0, "a=mid:0");
|
||||||
|
const frag = lines.join("\r\n");
|
||||||
|
try {
|
||||||
|
await this.doSignalingPATCH(frag, false);
|
||||||
|
} catch (e) {
|
||||||
|
this.logMessage(`Failed to trickle: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onSignalingStateChange() {
|
||||||
|
if (!this.peerConnection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logMessage(
|
||||||
|
`Signaling State changed: ${this.peerConnection.signalingState}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onICEConnectionStateChange() {
|
||||||
|
if (!this.peerConnection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logMessage(
|
||||||
|
`ICE Connection State changed: ${this.peerConnection.iceConnectionState}`
|
||||||
|
);
|
||||||
|
switch (this.peerConnection.iceConnectionState) {
|
||||||
|
case "checking":
|
||||||
|
this.iceStartTime = performance.now();
|
||||||
|
break;
|
||||||
|
case "connected":
|
||||||
|
const connected = performance.now();
|
||||||
|
if (this.connectStartTime) {
|
||||||
|
const delta = connected - this.connectStartTime;
|
||||||
|
this.logMessage(
|
||||||
|
`Took ${(delta / 1000).toFixed(
|
||||||
|
2
|
||||||
|
)} seconds to establish PeerConnection (end-to-end)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this.iceStartTime) {
|
||||||
|
const delta = connected - this.iceStartTime;
|
||||||
|
this.logMessage(
|
||||||
|
`Took ${(delta / 1000).toFixed(
|
||||||
|
2
|
||||||
|
)} seconds to establish PeerConnection (ICE)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent<StatusEvent>("status", {
|
||||||
|
detail: {
|
||||||
|
status: "connected",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
this.connecting = false;
|
||||||
|
this.connectedResolver();
|
||||||
|
this.stopTrickleBatching();
|
||||||
|
break;
|
||||||
|
case "failed":
|
||||||
|
if (this.connecting) {
|
||||||
|
this.connectedRejector("ICE failed while trying to connect");
|
||||||
|
this.stopTrickleBatching();
|
||||||
|
this.connecting = false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onTrack(ev: RTCTrackEvent) {
|
||||||
|
if (this.mode !== Mode.Player) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.remoteTracks.push(ev.track);
|
||||||
|
|
||||||
|
if (this.remoteTracks.length === 2) {
|
||||||
|
for (const track of this.remoteTracks) {
|
||||||
|
this.logMessage(`Got remote ${track.kind} track`);
|
||||||
|
if (this.playerMedia) {
|
||||||
|
this.playerMedia.addTrack(track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForICEGather() {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.gatherResolver();
|
||||||
|
}, 1000);
|
||||||
|
await this.gatherPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doSignaling() {
|
||||||
|
if (!this.peerConnection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.connectStartTime = performance.now();
|
||||||
|
const localOffer = await this.peerConnection.createOffer();
|
||||||
|
if (!localOffer.sdp) {
|
||||||
|
throw new Error("Fail to create offer");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.parsedOffer = SDPInfo.parse(localOffer.sdp);
|
||||||
|
let remoteOffer: string = "";
|
||||||
|
|
||||||
|
if (!this.useTrickle) {
|
||||||
|
await this.peerConnection.setLocalDescription(localOffer);
|
||||||
|
await this.waitForICEGather();
|
||||||
|
const offer = this.peerConnection.localDescription;
|
||||||
|
if (!offer) {
|
||||||
|
throw new Error("no LocalDescription");
|
||||||
|
}
|
||||||
|
remoteOffer = await this.doSignalingPOST(offer.sdp);
|
||||||
|
} else {
|
||||||
|
// ensure that resourceURL is set before trickle happens
|
||||||
|
remoteOffer = await this.doSignalingPOST(localOffer.sdp, true);
|
||||||
|
this.startTrickleBatching();
|
||||||
|
await this.peerConnection.setLocalDescription(localOffer);
|
||||||
|
}
|
||||||
|
await this.peerConnection.setRemoteDescription({
|
||||||
|
sdp: remoteOffer,
|
||||||
|
type: "answer",
|
||||||
|
});
|
||||||
|
this.connecting = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setVideoCodecPreference(transceiver: RTCRtpTransceiver) {
|
||||||
|
if (
|
||||||
|
typeof RTCRtpSender.getCapabilities === "undefined" ||
|
||||||
|
typeof transceiver.setCodecPreferences === "undefined"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const capability = RTCRtpSender.getCapabilities("video");
|
||||||
|
const codecs = capability ? capability.codecs : [];
|
||||||
|
this.logMessage(
|
||||||
|
`Available codecs for outbound video: ${codecs
|
||||||
|
.map((c) => c.mimeType)
|
||||||
|
.join(", ")}`
|
||||||
|
);
|
||||||
|
for (let i = 0; i < codecs.length; i++) {
|
||||||
|
const codec = codecs[i];
|
||||||
|
if (codec.mimeType === "video/VP9") {
|
||||||
|
codecs.unshift(codecs.splice(i, 1)[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transceiver.setCodecPreferences(codecs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async whipOffer(src: MediaStream) {
|
||||||
|
if (!this.peerConnection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const track of src.getTracks()) {
|
||||||
|
this.logMessage(`Adding local ${track.kind} track`);
|
||||||
|
const transceiver = this.peerConnection.addTransceiver(track, {
|
||||||
|
direction: "sendonly",
|
||||||
|
});
|
||||||
|
if (track.kind === "video") {
|
||||||
|
this.setVideoCodecPreference(transceiver);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.doSignaling();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async whepClientOffer() {
|
||||||
|
if (!this.peerConnection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.peerConnection.addTransceiver("video", {
|
||||||
|
direction: "recvonly",
|
||||||
|
});
|
||||||
|
this.peerConnection.addTransceiver("audio", {
|
||||||
|
direction: "recvonly",
|
||||||
|
});
|
||||||
|
await this.doSignaling();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateETag(resp: Response) {
|
||||||
|
const etag = resp.headers.get("etag");
|
||||||
|
if (etag) {
|
||||||
|
try {
|
||||||
|
this.etag = JSON.parse(etag);
|
||||||
|
} catch (e) {
|
||||||
|
this.logMessage("Failed to parse ETag header for PATCH");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.etag) {
|
||||||
|
this.logMessage(`Got ${this.etag} as ETag`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doSignalingPOST(
|
||||||
|
sdp: string,
|
||||||
|
useLink?: boolean
|
||||||
|
): Promise<string> {
|
||||||
|
if (!this.endpoint) {
|
||||||
|
throw new Error("No WHIP/WHEP endpoint has been set");
|
||||||
|
}
|
||||||
|
const signalStartTime = performance.now();
|
||||||
|
const resp = await fetch(this.endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
mode: "cors",
|
||||||
|
body: sdp,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/sdp",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await resp.text();
|
||||||
|
if (resp.status != 201) {
|
||||||
|
throw new Error(`Unexpected status code ${resp.status}: ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resource = resp.headers.get("location");
|
||||||
|
if (resource) {
|
||||||
|
if (resource.startsWith("http")) {
|
||||||
|
// absolute path
|
||||||
|
this.resourceURL = resource;
|
||||||
|
} else {
|
||||||
|
// relative path
|
||||||
|
const parsed = new URL(this.endpoint);
|
||||||
|
parsed.pathname = resource;
|
||||||
|
this.resourceURL = parsed.toString();
|
||||||
|
}
|
||||||
|
this.logMessage(`Using ${this.resourceURL} as WHIP/WHEP Resource URL`);
|
||||||
|
} else {
|
||||||
|
this.logMessage("No Location header in response");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateETag(resp);
|
||||||
|
|
||||||
|
if (resp.headers.get("accept-post") || resp.headers.get("accept-patch")) {
|
||||||
|
switch (this.mode) {
|
||||||
|
case Mode.Publisher:
|
||||||
|
this.logMessage(
|
||||||
|
`WHIP version draft-ietf-wish-whip-05 (Accept-Post/Accept-Patch)`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case Mode.Player:
|
||||||
|
this.logMessage(
|
||||||
|
`WHEP version draft-murillo-whep-01 (Accept-Post/Accept-Patch)`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.peerConnection && useLink) {
|
||||||
|
const link = resp.headers.get("link");
|
||||||
|
if (link) {
|
||||||
|
const links = parserLinkHeader(link);
|
||||||
|
if (links["ice-server"]) {
|
||||||
|
const url = links["ice-server"].url;
|
||||||
|
this.logMessage(`Endpoint provided ice-server ${url}`);
|
||||||
|
this.peerConnection.setConfiguration({
|
||||||
|
iceServers: [
|
||||||
|
{
|
||||||
|
urls: [url],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const signaled = performance.now();
|
||||||
|
const delta = signaled - signalStartTime;
|
||||||
|
this.logMessage(
|
||||||
|
`Took ${(delta / 1000).toFixed(2)} seconds to exchange SDP`
|
||||||
|
);
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doSignalingPATCH(frag: string, iceRestart: boolean) {
|
||||||
|
if (!this.resourceURL) {
|
||||||
|
throw new Error("No resource URL");
|
||||||
|
}
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
"content-type": "application/trickle-ice-sdpfrag",
|
||||||
|
};
|
||||||
|
if (this.etag) {
|
||||||
|
headers["if-match"] = this.etag;
|
||||||
|
}
|
||||||
|
const resp = await fetch(this.resourceURL, {
|
||||||
|
method: "PATCH",
|
||||||
|
mode: "cors",
|
||||||
|
body: frag,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
switch (resp.status) {
|
||||||
|
case 200:
|
||||||
|
if (iceRestart) {
|
||||||
|
this.updateETag(resp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// if we are doing an ice restart, we expect 200 OK
|
||||||
|
break;
|
||||||
|
case 204:
|
||||||
|
if (!iceRestart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// if we are doing trickle ice, we expect 204 No Content
|
||||||
|
break;
|
||||||
|
case 405:
|
||||||
|
case 501:
|
||||||
|
this.logMessage("Trickle ICE not supported, disabling");
|
||||||
|
this.useTrickle = false;
|
||||||
|
break;
|
||||||
|
case 412:
|
||||||
|
this.logMessage("Resource returns 412, session is outdated");
|
||||||
|
this.useTrickle = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const body = await resp.text();
|
||||||
|
throw new Error(`Unexpected status code ${resp.status}: ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async WithEndpoint(endpoint: string, trickle: boolean) {
|
||||||
|
if (endpoint === "") {
|
||||||
|
throw new Error("Endpoint cannot be empty");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(endpoint);
|
||||||
|
this.logMessage(`Using ${parsed.toString()} as the WHIP/WHEP Endpoint`);
|
||||||
|
this.useTrickle = trickle;
|
||||||
|
this.logMessage(`${trickle ? "Enabling" : "Disabling"} trickle ICE`);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error("Invalid Endpoint URL");
|
||||||
|
}
|
||||||
|
this.endpoint = endpoint;
|
||||||
|
this.resourceURL = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async Disconnect() {
|
||||||
|
this.endpoint = "";
|
||||||
|
this.killConnection();
|
||||||
|
if (!this.resourceURL) {
|
||||||
|
throw new Error("No resource URL");
|
||||||
|
}
|
||||||
|
const resp = await fetch(this.resourceURL, {
|
||||||
|
method: "DELETE",
|
||||||
|
mode: "cors",
|
||||||
|
});
|
||||||
|
if (resp.status != 200) {
|
||||||
|
const body = await resp.text();
|
||||||
|
throw new Error(`Unexpected status code ${resp.status}: ${body}`);
|
||||||
|
}
|
||||||
|
this.logMessage(`----- Disconnected via DELETE -----`);
|
||||||
|
this.resourceURL = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async Play(): Promise<MediaStream> {
|
||||||
|
this.mode = Mode.Player;
|
||||||
|
this.killConnection();
|
||||||
|
this.playerMedia = new MediaStream();
|
||||||
|
this.createConnection();
|
||||||
|
await this.whepClientOffer();
|
||||||
|
await this.connectedPromise;
|
||||||
|
return this.playerMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Publish(src: MediaStream) {
|
||||||
|
this.mode = Mode.Publisher;
|
||||||
|
this.killConnection();
|
||||||
|
this.createConnection();
|
||||||
|
await this.whipOffer(src);
|
||||||
|
await this.connectedPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ReplaceVideoTrack(src: MediaStream) {
|
||||||
|
if (!this.videoSender) {
|
||||||
|
throw new Error("Publisher is not active");
|
||||||
|
}
|
||||||
|
const tracks = src.getTracks();
|
||||||
|
if (tracks.length < 1) {
|
||||||
|
throw new Error("No tracks in MediaStream");
|
||||||
|
}
|
||||||
|
return await this.videoSender.replaceTrack(tracks[0]);
|
||||||
|
}
|
||||||
|
}
|
65
src/wish/parser.ts
Normal file
65
src/wish/parser.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
// adopted from https://github.com/thlorenz/parse-link-header
|
||||||
|
function parseLink(link: string): Link | null {
|
||||||
|
const matches = link.match(/<?([^>]*)>(.*)/);
|
||||||
|
if (!matches) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const linkUrl = matches[1];
|
||||||
|
const parts = matches[2].split(";");
|
||||||
|
const parsedUrl = new URL(linkUrl);
|
||||||
|
const qs = parsedUrl.searchParams;
|
||||||
|
|
||||||
|
parts.shift();
|
||||||
|
|
||||||
|
const initial: Link = { rel: "", url: linkUrl };
|
||||||
|
const reduced = parts.reduce((acc: Link, p) => {
|
||||||
|
const m = p.match(/\s*(.+)\s*=\s*"?([^"]+)"?/);
|
||||||
|
if (m) {
|
||||||
|
acc[m[1]] = m[2];
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, initial);
|
||||||
|
|
||||||
|
if (!reduced.rel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
qs.forEach((v, k) => {
|
||||||
|
reduced[k] = v;
|
||||||
|
});
|
||||||
|
|
||||||
|
return reduced;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://stackoverflow.com/a/46700791
|
||||||
|
function notEmpty<T>(value: T | null | undefined): value is T {
|
||||||
|
if (value === null || value === undefined) return false;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const testDummy: T = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Link {
|
||||||
|
rel: string;
|
||||||
|
url: string;
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Links {
|
||||||
|
[key: string]: Link;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parserLinkHeader(links: string): Links {
|
||||||
|
return links
|
||||||
|
.split(/,\s*</)
|
||||||
|
.map(parseLink)
|
||||||
|
.filter(notEmpty)
|
||||||
|
.reduce((links, l) => {
|
||||||
|
links[l.rel] = l;
|
||||||
|
return links;
|
||||||
|
}, {} as Links);
|
||||||
|
}
|
136
webpack.config.js
Normal file
136
webpack.config.js
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
// Generated using webpack-cli https://github.com/webpack/webpack-cli
|
||||||
|
|
||||||
|
const path = require("path");
|
||||||
|
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||||
|
const TerserPlugin = require("terser-webpack-plugin");
|
||||||
|
const ESLintPlugin = require("eslint-webpack-plugin");
|
||||||
|
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||||
|
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
|
||||||
|
const CopyPlugin = require("copy-webpack-plugin");
|
||||||
|
const TsTransformer = require("@formatjs/ts-transformer");
|
||||||
|
|
||||||
|
const isProduction = process.env.NODE_ENV == "production";
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
entry: {
|
||||||
|
main: "./src/index.tsx",
|
||||||
|
},
|
||||||
|
target: "browserslist",
|
||||||
|
devtool: isProduction ? "source-map" : "eval",
|
||||||
|
output: {
|
||||||
|
publicPath: "/",
|
||||||
|
path: path.resolve(__dirname, "build"),
|
||||||
|
filename: ({ runtime }) => {
|
||||||
|
if (runtime === "sw") {
|
||||||
|
return "[name].js";
|
||||||
|
}
|
||||||
|
return isProduction ? "[name].[chunkhash].js" : "[name].js";
|
||||||
|
},
|
||||||
|
clean: isProduction,
|
||||||
|
},
|
||||||
|
devServer: {
|
||||||
|
open: true,
|
||||||
|
host: "localhost",
|
||||||
|
historyApiFallback: true,
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new CopyPlugin({
|
||||||
|
patterns: [
|
||||||
|
{ from: "public/manifest.json" },
|
||||||
|
{ from: "public/robots.txt" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
template: "public/index.html",
|
||||||
|
favicon: "public/favicon.ico",
|
||||||
|
excludeChunks: ["sw"],
|
||||||
|
}),
|
||||||
|
new ESLintPlugin(),
|
||||||
|
new MiniCssExtractPlugin({
|
||||||
|
filename: isProduction ? "[name].[chunkhash].css" : "[name].css",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.tsx?$/i,
|
||||||
|
use: [
|
||||||
|
"babel-loader",
|
||||||
|
{
|
||||||
|
loader: "ts-loader",
|
||||||
|
options: {
|
||||||
|
getCustomTransformers() {
|
||||||
|
return {
|
||||||
|
before: [
|
||||||
|
TsTransformer.transform({
|
||||||
|
overrideIdFn: "[sha512:contenthash:base64:6]",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exclude: ["/node_modules/"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.css$/i,
|
||||||
|
use: [MiniCssExtractPlugin.loader, "css-loader"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif|webp)$/i,
|
||||||
|
type: "asset",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
optimization: {
|
||||||
|
usedExports: true,
|
||||||
|
chunkIds: "deterministic",
|
||||||
|
minimize: isProduction,
|
||||||
|
minimizer: [
|
||||||
|
"...",
|
||||||
|
// same as https://github.com/facebook/create-react-app/blob/main/packages/react-scripts/config/webpack.config.js
|
||||||
|
new TerserPlugin({
|
||||||
|
terserOptions: {
|
||||||
|
parse: {
|
||||||
|
ecma: 8,
|
||||||
|
},
|
||||||
|
compress: {
|
||||||
|
ecma: 5,
|
||||||
|
warnings: false,
|
||||||
|
comparisons: false,
|
||||||
|
inline: 2,
|
||||||
|
},
|
||||||
|
mangle: {
|
||||||
|
safari10: true,
|
||||||
|
},
|
||||||
|
keep_classnames: isProduction,
|
||||||
|
keep_fnames: isProduction,
|
||||||
|
output: {
|
||||||
|
ecma: 5,
|
||||||
|
comments: false,
|
||||||
|
ascii_only: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new CssMinimizerPlugin(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
extensions: [".tsx", ".ts", ".jsx", ".js", "..."],
|
||||||
|
modules: ["node_modules", __dirname, path.resolve(__dirname, "src")],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = () => {
|
||||||
|
if (isProduction) {
|
||||||
|
config.mode = "production";
|
||||||
|
config.entry.sw = {
|
||||||
|
import: "./src/service-worker.ts",
|
||||||
|
filename: "service-worker.js",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
config.mode = "development";
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user