├── .eslintrc.cjs
├── .gitignore
├── .prettierrc.json
├── .vscode
└── extensions.json
├── LICENSE
├── Makefile
├── README.md
├── env.d.ts
├── index.html
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
└── favicon.ico
├── src
├── App.vue
├── assets
│ └── main.css
├── components
│ ├── AccordionSection.vue
│ ├── AddressBar.vue
│ ├── AlertSection.vue
│ ├── FooterSection.vue
│ ├── HorizontalDivider.vue
│ ├── NavBar.vue
│ ├── SmallToggleGroup.vue
│ ├── SpinnerIcon.vue
│ └── StatusBadge.vue
├── lib
│ └── wish
│ │ ├── events.d.ts
│ │ ├── events.ts
│ │ ├── index.d.ts
│ │ ├── index.ts
│ │ ├── parser.d.ts
│ │ └── parser.ts
├── main.ts
├── router
│ └── index.ts
├── store
│ ├── notification.ts
│ └── setting.ts
├── types
│ ├── global.d.ts
│ └── level.d.ts
└── views
│ ├── 404View.vue
│ ├── HomeView.vue
│ ├── LiveView.vue
│ └── WatchView.vue
├── tailwind.config.js
├── tsconfig.config.json
├── tsconfig.json
└── vite.config.ts
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /* eslint-env node */
2 | require("@rushstack/eslint-patch/modern-module-resolution");
3 |
4 | module.exports = {
5 | root: true,
6 | extends: [
7 | "plugin:vue/vue3-essential",
8 | "eslint:recommended",
9 | "@vue/eslint-config-typescript",
10 | "@vue/eslint-config-prettier",
11 | ],
12 | parserOptions: {
13 | ecmaVersion: "latest",
14 | },
15 | overrides: [
16 | {
17 | files: ["*.config.js"],
18 | env: {
19 | node: true,
20 | },
21 | },
22 | ],
23 | };
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | .DS_Store
12 | dist
13 | dist-ssr
14 | coverage
15 | *.local
16 |
17 | /cypress/videos/
18 | /cypress/screenshots/
19 |
20 | # Editor directories and files
21 | .vscode/*
22 | !.vscode/extensions.json
23 | .idea
24 | *.suo
25 | *.ntvs*
26 | *.njsproj
27 | *.sln
28 | *.sw?
29 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {}
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
3 | }
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Rachel Chen
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | build:
2 | npm run build
3 |
4 | guard-%:
5 | @ if [ -z '${${*}}' ]; then echo 'Environment variable $* not set' && exit 1; fi
6 |
7 | publish: guard-CLOUDFLARE_ACCOUNT_ID
8 | npx wrangler pages publish dist/
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # vue-wish
2 |
3 | This template should help get you started developing with Vue 3 in Vite.
4 |
5 | ## Recommended IDE Setup
6 |
7 | [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
8 |
9 | ## Type Support for `.vue` Imports in TS
10 |
11 | TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
12 |
13 | If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
14 |
15 | 1. Disable the built-in TypeScript Extension
16 | 1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
17 | 2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
18 | 2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
19 |
20 | ## Customize configuration
21 |
22 | See [Vite Configuration Reference](https://vitejs.dev/config/).
23 |
24 | ## Project Setup
25 |
26 | ```sh
27 | npm install
28 | ```
29 |
30 | ### Compile and Hot-Reload for Development
31 |
32 | ```sh
33 | npm run dev
34 | ```
35 |
36 | ### Type-Check, Compile and Minify for Production
37 |
38 | ```sh
39 | npm run build
40 | ```
41 |
42 | ### Lint with [ESLint](https://eslint.org/)
43 |
44 | ```sh
45 | npm run lint
46 | ```
47 |
--------------------------------------------------------------------------------
/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | WebRTC Fun Time
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vue-wish",
3 | "version": "0.0.1",
4 | "scripts": {
5 | "dev": "vite",
6 | "build": "run-p type-check build-only",
7 | "preview": "vite preview",
8 | "build-only": "vite build",
9 | "type-check": "vue-tsc --noEmit",
10 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
11 | },
12 | "dependencies": {
13 | "vue": "^3.2.41",
14 | "vue-router": "^4.1.5"
15 | },
16 | "devDependencies": {
17 | "@headlessui/vue": "^1.7.4",
18 | "@heroicons/vue": "^2.0.13",
19 | "@rushstack/eslint-patch": "^1.1.4",
20 | "@tailwindcss/forms": "^0.5.3",
21 | "@types/node": "^16.11.68",
22 | "@types/webrtc": "^0.0.33",
23 | "@vitejs/plugin-vue": "^3.1.2",
24 | "@vue/eslint-config-prettier": "^7.0.0",
25 | "@vue/eslint-config-typescript": "^11.0.0",
26 | "@vue/tsconfig": "^0.1.3",
27 | "autoprefixer": "^10.4.13",
28 | "buffer": "^6.0.3",
29 | "eslint": "^8.22.0",
30 | "eslint-plugin-vue": "^9.3.0",
31 | "npm-run-all": "^4.1.5",
32 | "pinia": "^2.0.23",
33 | "pinia-plugin-persistedstate": "^2.3.0",
34 | "postcss": "^8.4.18",
35 | "prettier": "^2.7.1",
36 | "semantic-sdp": "^3.25.1",
37 | "tailwindcss": "^3.2.1",
38 | "typescript": "~4.7.4",
39 | "vite": "^3.1.8",
40 | "vue-tsc": "^1.0.8",
41 | "webrtc-adapter": "^8.2.0"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zllovesuki/vue-wish/7c6debc53f2f7b5ac9d3066a9d39d7e80f3fcc38/public/favicon.ico
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
26 |
27 |
35 |
39 |
40 |
41 |
42 |
45 | {{ notification.message }}
46 |
47 |
48 |
49 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
--------------------------------------------------------------------------------
/src/assets/main.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | audio::-webkit-media-controls-timeline,
6 | video::-webkit-media-controls-timeline {
7 | display: none !important;
8 | opacity: 0 !important;
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/AccordionSection.vue:
--------------------------------------------------------------------------------
1 |
27 |
28 |
29 |
30 |
38 |
45 |
53 | {{ title }}
54 |
55 |
63 |
64 |
80 |
81 |
82 |
89 |
90 |
91 |
92 |
--------------------------------------------------------------------------------
/src/components/AddressBar.vue:
--------------------------------------------------------------------------------
1 |
27 |
28 |
29 |
32 |
39 |
51 |
56 |
57 |
58 |
--------------------------------------------------------------------------------
/src/components/AlertSection.vue:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
12 |
13 |
18 |
23 |
28 |
29 |
30 |
31 | {{ message }}
32 |
33 |
34 |
35 |
36 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
140 |
--------------------------------------------------------------------------------
/src/components/FooterSection.vue:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 |
63 |
64 |
--------------------------------------------------------------------------------
/src/components/HorizontalDivider.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
--------------------------------------------------------------------------------
/src/components/NavBar.vue:
--------------------------------------------------------------------------------
1 |
29 |
30 |
31 |
36 |
37 |
38 |
39 |
53 |
54 |
55 | {{ nav.name }}
67 |
68 |
69 |
70 |
71 |
72 |
73 |
90 |
91 |
92 |
93 |
94 |
97 | Open main menu
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 | {{ nav.name }}
122 |
123 |
124 |
125 |
142 |
143 |
144 |
145 |
146 |
147 |
--------------------------------------------------------------------------------
/src/components/SmallToggleGroup.vue:
--------------------------------------------------------------------------------
1 |
21 |
22 |
23 |
24 |
28 | Use setting
29 |
33 |
42 |
49 |
50 |
51 | {{
52 | label
53 | }}
54 |
55 |
56 |
57 |
--------------------------------------------------------------------------------
/src/components/SpinnerIcon.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
18 |
Loading...
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/components/StatusBadge.vue:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 | {{ text }}
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/src/lib/wish/events.d.ts:
--------------------------------------------------------------------------------
1 | interface StateEventMap {
2 | log: CustomEvent;
3 | status: CustomEvent;
4 | }
5 | interface StateEventTarget extends EventTarget {
6 | addEventListener(
7 | type: K,
8 | listener: (ev: StateEventMap[K]) => void,
9 | options?: boolean | AddEventListenerOptions
10 | ): void;
11 | addEventListener(
12 | type: string,
13 | callback: EventListenerOrEventListenerObject | null,
14 | options?: EventListenerOptions | boolean
15 | ): void;
16 | }
17 | export declare const TypedEventTarget: {
18 | new (): StateEventTarget;
19 | prototype: StateEventTarget;
20 | };
21 | export interface LogEvent {
22 | message: string;
23 | }
24 | export interface StatusEvent {
25 | status: Status;
26 | }
27 | export declare type Status = "connected" | "disconnected";
28 | export {};
29 |
--------------------------------------------------------------------------------
/src/lib/wish/events.ts:
--------------------------------------------------------------------------------
1 | interface StateEventMap {
2 | log: CustomEvent;
3 | status: CustomEvent;
4 | }
5 |
6 | interface StateEventTarget extends EventTarget {
7 | addEventListener(
8 | type: K,
9 | listener: (ev: StateEventMap[K]) => void,
10 | options?: boolean | AddEventListenerOptions
11 | ): void;
12 | addEventListener(
13 | type: string,
14 | callback: EventListenerOrEventListenerObject | null,
15 | options?: EventListenerOptions | boolean
16 | ): void;
17 | }
18 |
19 | export const TypedEventTarget = EventTarget as {
20 | new (): StateEventTarget;
21 | prototype: StateEventTarget;
22 | };
23 |
24 | export interface LogEvent {
25 | message: string;
26 | }
27 |
28 | export interface StatusEvent {
29 | status: Status;
30 | }
31 |
32 | export type Status = "connected" | "disconnected";
33 |
--------------------------------------------------------------------------------
/src/lib/wish/index.d.ts:
--------------------------------------------------------------------------------
1 | import { TypedEventTarget } from "./events";
2 | export declare const DEFAULT_ICE_SERVERS: string[];
3 | export declare const TRICKLE_BATCH_INTERVAL: number;
4 | export declare class WISH extends TypedEventTarget {
5 | private peerConnection?;
6 | private iceServers;
7 | private videoSender?;
8 | private remoteTracks;
9 | private playerMedia?;
10 | private connecting;
11 | private connectedPromise;
12 | private connectedResolver;
13 | private connectedRejector;
14 | private gatherPromise;
15 | private gatherResolver;
16 | private endpoint?;
17 | private resourceURL?;
18 | private mode;
19 | private parsedOffer?;
20 | private useTrickle;
21 | private etag?;
22 | private providedIceServer?;
23 | private trickleBatchingJob?;
24 | private batchedCandidates;
25 | constructor(iceServers?: string[]);
26 | private logMessage;
27 | private killConnection;
28 | private createConnection;
29 | private newResolvers;
30 | private addEventListeners;
31 | private onGatheringStateChange;
32 | private onConnectionStateChange;
33 | private onICECandidate;
34 | private startTrickleBatching;
35 | private stopTrickleBatching;
36 | private trickleBatch;
37 | private onSignalingStateChange;
38 | private onICEConnectionStateChange;
39 | private onTrack;
40 | private waitForICEGather;
41 | private doSignaling;
42 | private whipOffer;
43 | private whepClientOffer;
44 | private doSignalingPOST;
45 | private doSignalingPATCH;
46 | private checkEndpoint;
47 | WithEndpoint(endpoint: string, trickle: boolean): Promise;
48 | Disconnect(): Promise;
49 | Play(): Promise;
50 | Publish(src: MediaStream): Promise;
51 | ReplaceVideoTrack(src: MediaStream): Promise;
52 | }
53 |
--------------------------------------------------------------------------------
/src/lib/wish/index.ts:
--------------------------------------------------------------------------------
1 | import adapter from "webrtc-adapter";
2 | import { CandidateInfo, SDPInfo } from "semantic-sdp";
3 | import { TypedEventTarget, type StatusEvent, type LogEvent } from "./events";
4 | import { parserLinkHeader } from "./parser";
5 |
6 | export const DEFAULT_ICE_SERVERS = [
7 | "stun:stun.cloudflare.com:3478",
8 | "stun:stun.l.google.com:19302",
9 | ];
10 |
11 | export const TRICKLE_BATCH_INTERVAL = 50;
12 |
13 | enum Mode {
14 | Player = "player",
15 | Publisher = "publisher",
16 | }
17 |
18 | export class WISH extends TypedEventTarget {
19 | private peerConnection?: RTCPeerConnection;
20 | private iceServers: string[] = DEFAULT_ICE_SERVERS;
21 |
22 | private videoSender?: RTCRtpSender;
23 |
24 | private remoteTracks: MediaStreamTrack[] = [];
25 | private playerMedia?: MediaStream;
26 |
27 | private connecting: boolean = false;
28 | private connectedPromise!: Promise;
29 | private connectedResolver!: (any: void) => void;
30 | private connectedRejector!: (reason?: any) => void;
31 | private gatherPromise!: Promise;
32 | private gatherResolver!: (any: void) => void;
33 |
34 | private endpoint?: string;
35 | private resourceURL?: string;
36 | private mode: Mode = Mode.Player;
37 | private parsedOffer?: SDPInfo;
38 | private useTrickle: boolean = false;
39 | private etag?: string;
40 |
41 | private trickleBatchingJob?: number;
42 | private batchedCandidates: RTCIceCandidate[] = [];
43 |
44 | private connectStartTime?: number;
45 | private iceStartTime?: number;
46 |
47 | constructor(iceServers?: string[]) {
48 | super();
49 | if (iceServers) {
50 | this.iceServers = iceServers ? iceServers : DEFAULT_ICE_SERVERS;
51 | }
52 | this.logMessage(
53 | `Enabling webrtc-adapter for ${adapter.browserDetails.browser}@${adapter.browserDetails.version}`
54 | );
55 | this.newResolvers();
56 | }
57 |
58 | private logMessage(str: string) {
59 | const now = new Date().toLocaleString();
60 | console.log(`${now}: ${str}`);
61 | this.dispatchEvent(
62 | new CustomEvent("log", {
63 | detail: {
64 | message: str,
65 | },
66 | })
67 | );
68 | }
69 |
70 | private killConnection() {
71 | if (this.peerConnection) {
72 | this.logMessage("Closing RTCPeerConnection");
73 | this.peerConnection.close();
74 | this.peerConnection = undefined;
75 | this.parsedOffer = undefined;
76 | this.playerMedia = undefined;
77 | this.videoSender = undefined;
78 | this.connecting = false;
79 | this.remoteTracks = [];
80 | this.batchedCandidates = [];
81 | this.stopTrickleBatching();
82 | }
83 | }
84 |
85 | private createConnection() {
86 | this.logMessage("Creating a new RTCPeerConnection");
87 | this.peerConnection = new RTCPeerConnection({
88 | iceServers: [{ urls: this.iceServers }],
89 | });
90 | if (!this.peerConnection) {
91 | throw new Error("Failed to create a new RTCPeerConnection");
92 | }
93 | this.addEventListeners();
94 | this.newResolvers();
95 | }
96 |
97 | private newResolvers() {
98 | this.connectedPromise = new Promise((resolve, reject) => {
99 | this.connectedResolver = resolve;
100 | this.connectedRejector = reject;
101 | });
102 | this.gatherPromise = new Promise((resolve) => {
103 | this.gatherResolver = resolve;
104 | });
105 | }
106 |
107 | private addEventListeners() {
108 | if (!this.peerConnection) {
109 | return;
110 | }
111 | this.peerConnection.addEventListener(
112 | "connectionstatechange",
113 | this.onConnectionStateChange.bind(this)
114 | );
115 | this.peerConnection.addEventListener(
116 | "iceconnectionstatechange",
117 | this.onICEConnectionStateChange.bind(this)
118 | );
119 | this.peerConnection.addEventListener(
120 | "icegatheringstatechange",
121 | this.onGatheringStateChange.bind(this)
122 | );
123 | this.peerConnection.addEventListener(
124 | "icecandidate",
125 | this.onICECandidate.bind(this)
126 | );
127 | this.peerConnection.addEventListener("track", this.onTrack.bind(this));
128 | this.peerConnection.addEventListener(
129 | "signalingstatechange",
130 | this.onSignalingStateChange.bind(this)
131 | );
132 | }
133 |
134 | private onGatheringStateChange() {
135 | if (!this.peerConnection) {
136 | return;
137 | }
138 | this.logMessage(
139 | `ICE Gathering State changed: ${this.peerConnection.iceGatheringState}`
140 | );
141 | switch (this.peerConnection.iceGatheringState) {
142 | case "complete":
143 | this.gatherResolver();
144 | break;
145 | }
146 | }
147 |
148 | private onConnectionStateChange() {
149 | if (!this.peerConnection) {
150 | return;
151 | }
152 | this.logMessage(
153 | `Peer Connection State changed: ${this.peerConnection.connectionState}`
154 | );
155 | const transportHandler = (
156 | track: MediaStreamTrack,
157 | transport: RTCDtlsTransport
158 | ) => {
159 | const ice = transport.iceTransport;
160 | if (!ice) {
161 | return;
162 | }
163 | const pair = ice.getSelectedCandidatePair();
164 | if (!pair) {
165 | return;
166 | }
167 | if (pair.local && pair.remote) {
168 | this.logMessage(
169 | `[${track.kind}] Selected Candidate: (local ${pair.local.address})-(remote ${pair.remote.candidate})`
170 | );
171 | }
172 | };
173 | switch (this.peerConnection.connectionState) {
174 | case "connected":
175 | switch (this.mode) {
176 | case Mode.Player:
177 | for (const receiver of this.peerConnection.getReceivers()) {
178 | const transport = receiver.transport;
179 | if (!transport) {
180 | continue;
181 | }
182 | transportHandler(receiver.track, transport);
183 | }
184 | break;
185 | case Mode.Publisher:
186 | for (const sender of this.peerConnection.getSenders()) {
187 | const transport = sender.transport;
188 | if (!transport) {
189 | continue;
190 | }
191 | if (!sender.track) {
192 | continue;
193 | }
194 | if (sender.track.kind === "video") {
195 | this.videoSender = sender;
196 | }
197 | transportHandler(sender.track, transport);
198 | }
199 | break;
200 | }
201 | break;
202 | case "failed":
203 | this.dispatchEvent(
204 | new CustomEvent("status", {
205 | detail: {
206 | status: "disconnected",
207 | },
208 | })
209 | );
210 | break;
211 | }
212 | }
213 |
214 | private onICECandidate(ev: RTCPeerConnectionIceEvent) {
215 | if (ev.candidate) {
216 | const candidate = ev.candidate;
217 | if (!candidate.candidate) {
218 | return;
219 | }
220 | this.logMessage(
221 | `Got ICE candidate: ${candidate.candidate.replace("candidate:", "")}`
222 | );
223 | if (!this.parsedOffer) {
224 | return;
225 | }
226 | if (!this.useTrickle) {
227 | return;
228 | }
229 | if (candidate.candidate.includes(".local")) {
230 | this.logMessage("Skipping mDNS candidate for trickle ICE");
231 | return;
232 | }
233 | this.batchedCandidates.push(candidate);
234 | } else {
235 | this.logMessage(`End of ICE candidates`);
236 | }
237 | }
238 |
239 | private startTrickleBatching() {
240 | if (this.trickleBatchingJob) {
241 | clearInterval(this.trickleBatchingJob);
242 | }
243 | this.logMessage(
244 | `Starting batching job to trickle candidates every ${TRICKLE_BATCH_INTERVAL}ms`
245 | );
246 | this.trickleBatchingJob = setInterval(
247 | this.trickleBatch.bind(this),
248 | TRICKLE_BATCH_INTERVAL
249 | );
250 | }
251 |
252 | private stopTrickleBatching() {
253 | if (!this.trickleBatchingJob) {
254 | return;
255 | }
256 | this.logMessage("Stopping trickle batching job");
257 | clearInterval(this.trickleBatchingJob);
258 | this.trickleBatchingJob = undefined;
259 | }
260 |
261 | private async trickleBatch() {
262 | if (!this.parsedOffer) {
263 | return;
264 | }
265 | if (!this.batchedCandidates.length) {
266 | return;
267 | }
268 |
269 | const fragSDP = new SDPInfo();
270 | const candidates = this.batchedCandidates.splice(0);
271 | this.logMessage(`Tricking with ${candidates.length} candidates`);
272 |
273 | for (const candidate of candidates) {
274 | const candidateObject = CandidateInfo.expand({
275 | foundation: candidate.foundation || "",
276 | componentId: candidate.component === "rtp" ? 1 : 2,
277 | transport: candidate.protocol || "udp",
278 | priority: candidate.priority || 0,
279 | address: candidate.address || "",
280 | port: candidate.port || 0,
281 | type: candidate.type || "host",
282 | relAddr: candidate.relatedAddress || undefined,
283 | relPort:
284 | typeof candidate.relatedPort !== "undefined" &&
285 | candidate.relatedPort !== null
286 | ? candidate.relatedPort.toString()
287 | : undefined,
288 | });
289 | fragSDP.addCandidate(candidateObject);
290 | }
291 | fragSDP.setICE(this.parsedOffer.getICE());
292 |
293 | const generated = fragSDP.toIceFragmentString();
294 | // for trickle-ice-sdpfrag, we need a psuedo m= line
295 | const lines = generated.split(/\r?\n/);
296 | lines.splice(2, 0, "m=audio 9 RTP/AVP 0");
297 | lines.splice(3, 0, "a=mid:0");
298 | const frag = lines.join("\r\n");
299 | try {
300 | await this.doSignalingPATCH(frag, false);
301 | } catch (e) {
302 | this.logMessage(`Failed to trickle: ${(e as Error).message}`);
303 | }
304 | }
305 |
306 | private onSignalingStateChange() {
307 | if (!this.peerConnection) {
308 | return;
309 | }
310 | this.logMessage(
311 | `Signaling State changed: ${this.peerConnection.signalingState}`
312 | );
313 | }
314 |
315 | private onICEConnectionStateChange() {
316 | if (!this.peerConnection) {
317 | return;
318 | }
319 | this.logMessage(
320 | `ICE Connection State changed: ${this.peerConnection.iceConnectionState}`
321 | );
322 | switch (this.peerConnection.iceConnectionState) {
323 | case "checking":
324 | this.iceStartTime = performance.now();
325 | break;
326 | case "connected":
327 | const connected = performance.now();
328 | if (this.connectStartTime) {
329 | const delta = connected - this.connectStartTime;
330 | this.logMessage(
331 | `Took ${(delta / 1000).toFixed(
332 | 2
333 | )} seconds to establish PeerConnection (end-to-end)`
334 | );
335 | }
336 | if (this.iceStartTime) {
337 | const delta = connected - this.iceStartTime;
338 | this.logMessage(
339 | `Took ${(delta / 1000).toFixed(
340 | 2
341 | )} seconds to establish PeerConnection (ICE)`
342 | );
343 | }
344 | this.dispatchEvent(
345 | new CustomEvent("status", {
346 | detail: {
347 | status: "connected",
348 | },
349 | })
350 | );
351 | this.connecting = false;
352 | this.connectedResolver();
353 | this.stopTrickleBatching();
354 | break;
355 | case "failed":
356 | if (this.connecting) {
357 | this.connectedRejector("ICE failed while trying to connect");
358 | this.stopTrickleBatching();
359 | this.connecting = false;
360 | }
361 | break;
362 | }
363 | }
364 |
365 | private onTrack(ev: RTCTrackEvent) {
366 | if (this.mode !== Mode.Player) {
367 | return;
368 | }
369 | this.remoteTracks.push(ev.track);
370 |
371 | if (this.remoteTracks.length === 2) {
372 | for (const track of this.remoteTracks) {
373 | this.logMessage(`Got remote ${track.kind} track`);
374 | if (this.playerMedia) {
375 | this.playerMedia.addTrack(track);
376 | }
377 | }
378 | }
379 | }
380 |
381 | private async waitForICEGather() {
382 | setTimeout(() => {
383 | this.gatherResolver();
384 | }, 1000);
385 | await this.gatherPromise;
386 | }
387 |
388 | private async doSignaling() {
389 | if (!this.peerConnection) {
390 | return;
391 | }
392 | this.connectStartTime = performance.now();
393 | const localOffer = await this.peerConnection.createOffer();
394 | if (!localOffer.sdp) {
395 | throw new Error("Fail to create offer");
396 | }
397 |
398 | this.parsedOffer = SDPInfo.parse(localOffer.sdp);
399 | let remoteOffer: string = "";
400 |
401 | if (!this.useTrickle) {
402 | await this.peerConnection.setLocalDescription(localOffer);
403 | await this.waitForICEGather();
404 | const offer = this.peerConnection.localDescription;
405 | if (!offer) {
406 | throw new Error("no LocalDescription");
407 | }
408 | remoteOffer = await this.doSignalingPOST(offer.sdp);
409 | } else {
410 | // ensure that resourceURL is set before trickle happens
411 | remoteOffer = await this.doSignalingPOST(localOffer.sdp, true);
412 | this.startTrickleBatching();
413 | await this.peerConnection.setLocalDescription(localOffer);
414 | }
415 | await this.peerConnection.setRemoteDescription({
416 | sdp: remoteOffer,
417 | type: "answer",
418 | });
419 | this.connecting = true;
420 | }
421 |
422 | private setVideoCodecPreference(transceiver: RTCRtpTransceiver) {
423 | if (
424 | typeof RTCRtpSender.getCapabilities === "undefined" ||
425 | typeof transceiver.setCodecPreferences === "undefined"
426 | ) {
427 | return;
428 | }
429 | const capability = RTCRtpSender.getCapabilities("video");
430 | const codecs = capability ? capability.codecs : [];
431 | this.logMessage(
432 | `Available codecs for outbound video: ${codecs
433 | .map((c) => c.mimeType)
434 | .join(", ")}`
435 | );
436 | for (let i = 0; i < codecs.length; i++) {
437 | const codec = codecs[i];
438 | if (codec.mimeType === "video/VP9") {
439 | codecs.unshift(codecs.splice(i, 1)[0]);
440 | }
441 | }
442 | transceiver.setCodecPreferences(codecs);
443 | }
444 |
445 | private async whipOffer(src: MediaStream) {
446 | if (!this.peerConnection) {
447 | return;
448 | }
449 | for (const track of src.getTracks()) {
450 | this.logMessage(`Adding local ${track.kind} track`);
451 | const transceiver = this.peerConnection.addTransceiver(track, {
452 | direction: "sendonly",
453 | });
454 | if (track.kind === "video") {
455 | this.setVideoCodecPreference(transceiver);
456 | }
457 | }
458 | await this.doSignaling();
459 | }
460 |
461 | private async whepClientOffer() {
462 | if (!this.peerConnection) {
463 | return;
464 | }
465 | this.peerConnection.addTransceiver("video", {
466 | direction: "recvonly",
467 | });
468 | this.peerConnection.addTransceiver("audio", {
469 | direction: "recvonly",
470 | });
471 | await this.doSignaling();
472 | }
473 |
474 | private updateETag(resp: Response) {
475 | const etag = resp.headers.get("etag");
476 | if (etag) {
477 | try {
478 | this.etag = JSON.parse(etag);
479 | } catch (e) {
480 | this.logMessage("Failed to parse ETag header for PATCH");
481 | }
482 | }
483 | if (this.etag) {
484 | this.logMessage(`Got ${this.etag} as ETag`);
485 | }
486 | }
487 |
488 | private async doSignalingPOST(
489 | sdp: string,
490 | useLink?: boolean
491 | ): Promise {
492 | if (!this.endpoint) {
493 | throw new Error("No WHIP/WHEP endpoint has been set");
494 | }
495 | const signalStartTime = performance.now();
496 | const resp = await fetch(this.endpoint, {
497 | method: "POST",
498 | mode: "cors",
499 | body: sdp,
500 | headers: {
501 | "content-type": "application/sdp",
502 | },
503 | });
504 | const body = await resp.text();
505 | if (resp.status != 201) {
506 | throw new Error(`Unexpected status code ${resp.status}: ${body}`);
507 | }
508 |
509 | const resource = resp.headers.get("location");
510 | if (resource) {
511 | if (resource.startsWith("http")) {
512 | // absolute path
513 | this.resourceURL = resource;
514 | } else {
515 | // relative path
516 | const parsed = new URL(this.endpoint);
517 | parsed.pathname = resource;
518 | this.resourceURL = parsed.toString();
519 | }
520 | this.logMessage(`Using ${this.resourceURL} as WHIP/WHEP Resource URL`);
521 | } else {
522 | this.logMessage("No Location header in response");
523 | }
524 |
525 | this.updateETag(resp);
526 |
527 | if (resp.headers.get("accept-post") || resp.headers.get("accept-patch")) {
528 | switch (this.mode) {
529 | case Mode.Publisher:
530 | this.logMessage(
531 | `WHIP version draft-ietf-wish-whip-05 (Accept-Post/Accept-Patch)`
532 | );
533 | break;
534 | case Mode.Player:
535 | this.logMessage(
536 | `WHEP version draft-murillo-whep-01 (Accept-Post/Accept-Patch)`
537 | );
538 | break;
539 | }
540 | }
541 |
542 | if (this.peerConnection && useLink) {
543 | const link = resp.headers.get("link");
544 | if (link) {
545 | const links = parserLinkHeader(link);
546 | if (links["ice-server"]) {
547 | const url = links["ice-server"].url;
548 | this.logMessage(`Endpoint provided ice-server ${url}`);
549 | this.peerConnection.setConfiguration({
550 | iceServers: [
551 | {
552 | urls: [url],
553 | },
554 | ],
555 | });
556 | }
557 | }
558 | }
559 |
560 | const signaled = performance.now();
561 | const delta = signaled - signalStartTime;
562 | this.logMessage(
563 | `Took ${(delta / 1000).toFixed(2)} seconds to exchange SDP`
564 | );
565 |
566 | return body;
567 | }
568 |
569 | private async doSignalingPATCH(frag: string, iceRestart: boolean) {
570 | if (!this.resourceURL) {
571 | throw new Error("No resource URL");
572 | }
573 | const headers: HeadersInit = {
574 | "content-type": "application/trickle-ice-sdpfrag",
575 | };
576 | if (this.etag) {
577 | headers["if-match"] = this.etag;
578 | }
579 | const resp = await fetch(this.resourceURL, {
580 | method: "PATCH",
581 | mode: "cors",
582 | body: frag,
583 | headers,
584 | });
585 | switch (resp.status) {
586 | case 200:
587 | if (iceRestart) {
588 | this.updateETag(resp);
589 | return;
590 | }
591 | // if we are doing an ice restart, we expect 200 OK
592 | break;
593 | case 204:
594 | if (!iceRestart) {
595 | return;
596 | }
597 | // if we are doing trickle ice, we expect 204 No Content
598 | break;
599 | case 405:
600 | case 501:
601 | this.logMessage("Trickle ICE not supported, disabling");
602 | this.useTrickle = false;
603 | break;
604 | case 412:
605 | this.logMessage("Resource returns 412, session is outdated");
606 | this.useTrickle = false;
607 | break;
608 | }
609 | const body = await resp.text();
610 | throw new Error(`Unexpected status code ${resp.status}: ${body}`);
611 | }
612 |
613 | async WithEndpoint(endpoint: string, trickle: boolean) {
614 | if (endpoint === "") {
615 | throw new Error("Endpoint cannot be empty");
616 | }
617 | try {
618 | const parsed = new URL(endpoint);
619 | this.logMessage(`Using ${parsed.toString()} as the WHIP/WHEP Endpoint`);
620 | this.useTrickle = trickle;
621 | this.logMessage(`${trickle ? "Enabling" : "Disabling"} trickle ICE`);
622 | } catch (e) {
623 | throw new Error("Invalid Endpoint URL");
624 | }
625 | this.endpoint = endpoint;
626 | this.resourceURL = "";
627 | }
628 |
629 | async Disconnect() {
630 | this.endpoint = "";
631 | this.killConnection();
632 | if (!this.resourceURL) {
633 | throw new Error("No resource URL");
634 | }
635 | const resp = await fetch(this.resourceURL, {
636 | method: "DELETE",
637 | mode: "cors",
638 | });
639 | if (resp.status != 200) {
640 | const body = await resp.text();
641 | throw new Error(`Unexpected status code ${resp.status}: ${body}`);
642 | }
643 | this.logMessage(`----- Disconnected via DELETE -----`);
644 | this.resourceURL = "";
645 | }
646 |
647 | async Play(): Promise {
648 | this.mode = Mode.Player;
649 | this.killConnection();
650 | this.playerMedia = new MediaStream();
651 | this.createConnection();
652 | await this.whepClientOffer();
653 | await this.connectedPromise;
654 | return this.playerMedia;
655 | }
656 |
657 | async Publish(src: MediaStream) {
658 | this.mode = Mode.Publisher;
659 | this.killConnection();
660 | this.createConnection();
661 | await this.whipOffer(src);
662 | await this.connectedPromise;
663 | }
664 |
665 | async ReplaceVideoTrack(src: MediaStream) {
666 | if (!this.videoSender) {
667 | throw new Error("Publisher is not active");
668 | }
669 | const tracks = src.getTracks();
670 | if (tracks.length < 1) {
671 | throw new Error("No tracks in MediaStream");
672 | }
673 | return await this.videoSender.replaceTrack(tracks[0]);
674 | }
675 | }
676 |
--------------------------------------------------------------------------------
/src/lib/wish/parser.d.ts:
--------------------------------------------------------------------------------
1 | export interface Link {
2 | rel: string;
3 | url: string;
4 | [key: string]: string;
5 | }
6 | export interface Links {
7 | [key: string]: Link;
8 | }
9 | export declare function parserLinkHeader(links: string): Links;
10 | export {};
11 |
--------------------------------------------------------------------------------
/src/lib/wish/parser.ts:
--------------------------------------------------------------------------------
1 | // adopted from https://github.com/thlorenz/parse-link-header
2 | function parseLink(link: string): Link | null {
3 | const matches = link.match(/([^>]*)>(.*)/);
4 | if (!matches) {
5 | return null;
6 | }
7 | try {
8 | const linkUrl = matches[1];
9 | const parts = matches[2].split(";");
10 | const parsedUrl = new URL(linkUrl);
11 | const qs = parsedUrl.searchParams;
12 |
13 | parts.shift();
14 |
15 | const initial: Link = { rel: "", url: linkUrl };
16 | const reduced = parts.reduce((acc: Link, p) => {
17 | const m = p.match(/\s*(.+)\s*=\s*"?([^"]+)"?/);
18 | if (m) {
19 | acc[m[1]] = m[2];
20 | }
21 | return acc;
22 | }, initial);
23 |
24 | if (!reduced.rel) {
25 | return null;
26 | }
27 |
28 | qs.forEach((v, k) => {
29 | reduced[k] = v;
30 | });
31 |
32 | return reduced;
33 | } catch (e) {
34 | return null;
35 | }
36 | }
37 |
38 | // https://stackoverflow.com/a/46700791
39 | function notEmpty(value: T | null | undefined): value is T {
40 | if (value === null || value === undefined) return false;
41 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
42 | const testDummy: T = value;
43 | return true;
44 | }
45 |
46 | export interface Link {
47 | rel: string;
48 | url: string;
49 | [key: string]: string;
50 | }
51 |
52 | export interface Links {
53 | [key: string]: Link;
54 | }
55 |
56 | export function parserLinkHeader(links: string): Links {
57 | return links
58 | .split(/,\s*)
59 | .map(parseLink)
60 | .filter(notEmpty)
61 | .reduce((links, l) => {
62 | links[l.rel] = l;
63 | return links;
64 | }, {} as Links);
65 | }
66 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | window.global ||= window;
2 | import { createApp, watchEffect, h } from "vue";
3 | import { createPinia } from "pinia";
4 | import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
5 | import { useSettingStore } from "./store/setting";
6 |
7 | import App from "./App.vue";
8 | import router from "./router";
9 |
10 | const pinia = createPinia();
11 | pinia.use(piniaPluginPersistedstate);
12 |
13 | const app = createApp({
14 | setup() {
15 | const setting = useSettingStore();
16 | const applyDarkMode = () =>
17 | document.documentElement.classList[setting.darkMode ? "add" : "remove"](
18 | "dark"
19 | );
20 | watchEffect(applyDarkMode);
21 | },
22 | render: () => h(App),
23 | });
24 |
25 | app.use(router);
26 | app.use(pinia);
27 |
28 | app.mount("#app");
29 |
--------------------------------------------------------------------------------
/src/router/index.ts:
--------------------------------------------------------------------------------
1 | import { createRouter, createWebHistory } from "vue-router";
2 | import HomeView from "@/views/HomeView.vue";
3 |
4 | const router = createRouter({
5 | history: createWebHistory(import.meta.env.BASE_URL),
6 | routes: [
7 | {
8 | path: "/",
9 | name: "home",
10 | component: HomeView,
11 | },
12 | {
13 | path: "/watch",
14 | name: "watch",
15 | component: () => import("@/views/WatchView.vue"),
16 | },
17 | {
18 | path: "/live",
19 | name: "live",
20 | component: () => import("@/views/LiveView.vue"),
21 | },
22 | {
23 | path: "/:pathMatch(.*)",
24 | name: "not-found",
25 | component: () => import("@/views/404View.vue"),
26 | },
27 | ],
28 | });
29 |
30 | export default router;
31 |
--------------------------------------------------------------------------------
/src/store/notification.ts:
--------------------------------------------------------------------------------
1 | import { ref } from "vue";
2 | import { defineStore } from "pinia";
3 |
4 | export const useNotificationStore = defineStore("notification", () => {
5 | const show = ref(false);
6 | const message = ref("");
7 |
8 | const notify = (msg: string) => {
9 | message.value = msg;
10 | show.value = true;
11 | };
12 |
13 | return {
14 | show,
15 | message,
16 | notify,
17 | };
18 | });
19 |
--------------------------------------------------------------------------------
/src/store/setting.ts:
--------------------------------------------------------------------------------
1 | import { ref } from "vue";
2 | import { defineStore } from "pinia";
3 |
4 | export const useSettingStore = defineStore(
5 | "setting",
6 | () => {
7 | let dark = false;
8 | if (
9 | window.matchMedia &&
10 | window.matchMedia("(prefers-color-scheme: dark)").matches
11 | ) {
12 | dark = true;
13 | }
14 | const trickle = ref(true);
15 | const darkMode = ref(dark);
16 |
17 | const lastLive = ref("");
18 |
19 | return {
20 | trickle,
21 | darkMode,
22 | lastLive,
23 | };
24 | },
25 | {
26 | persist: true,
27 | }
28 | );
29 |
--------------------------------------------------------------------------------
/src/types/global.d.ts:
--------------------------------------------------------------------------------
1 | export {};
2 |
3 | declare global {
4 | interface Window {
5 | global: any;
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/types/level.d.ts:
--------------------------------------------------------------------------------
1 | export type AlertLevel = "fail" | "success" | "info";
2 |
--------------------------------------------------------------------------------
/src/views/404View.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
10 | 404
11 |
12 |
13 |
14 |
17 | Page not found
18 |
19 |
20 | Please check the URL in the address bar and try again.
21 |
22 |
23 |
26 | Go back home
31 |
32 |
33 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/src/views/HomeView.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
11 | Experience the cutting edge technology
12 |
13 |
14 | Watch a stream via WHEP, or go live via WHIP, all within your
15 | browser
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/src/views/LiveView.vue:
--------------------------------------------------------------------------------
1 |
360 |
361 |
362 |
363 |
371 |
375 |
376 | Go Live via
377 | WHIP
380 |
381 |
382 | Publish an WebRTC stream with sub-second latency to viewers
383 |
384 |
385 |
386 |
387 |
388 |
389 |
397 |
407 |
411 |
412 |
413 |
414 |
421 |
422 |
423 |
424 |
425 |
426 |
427 |
428 |
431 | You are ready to go live!
432 |
433 |
434 |
435 |
436 |
437 |
438 |
439 |
440 |
441 |
442 |
443 |
444 |
445 |
480 |
481 |
482 |
483 |
484 |
485 |
486 |
487 |
488 |
489 |
492 | First, choose sources
493 |
494 |
495 |
499 |
500 |
501 |
502 |
503 |
504 |
505 |
506 |
507 |
508 |
509 |
510 |
511 |
512 |
513 |
514 |
515 |
516 |
520 |
521 |
522 |
523 |
524 |
525 |
526 |
527 |
528 |
529 |
530 |
531 |
622 |
623 |
624 |
625 |
626 |
627 |
628 |
632 |
633 | -
634 | {{ log }}
635 |
636 |
637 |
638 |
639 |
640 |
641 |
642 |
--------------------------------------------------------------------------------
/src/views/WatchView.vue:
--------------------------------------------------------------------------------
1 |
119 |
120 |
121 |
122 |
130 |
134 |
135 | Watch via
136 | WHEP
141 |
142 |
143 | Play an WebRTC stream with sub-second latency
144 |
145 |
146 |
147 |
148 |
149 |
150 |
157 |
158 |
162 |
163 |
164 |
165 |
172 |
173 |
174 |
175 |
185 |
186 |
187 |
191 |
192 | -
193 | {{ log }}
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | const defaultTheme = require("tailwindcss/defaultTheme");
3 | module.exports = {
4 | darkMode: "class",
5 | content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
6 | theme: {
7 | extend: {
8 | fontFamily: {
9 | sans: ["Inter var", ...defaultTheme.fontFamily.sans],
10 | },
11 | },
12 | },
13 | plugins: [require("@tailwindcss/forms")],
14 | };
15 |
--------------------------------------------------------------------------------
/tsconfig.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@vue/tsconfig/tsconfig.node.json",
3 | "include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "playwright.config.*"],
4 | "compilerOptions": {
5 | "composite": true,
6 | "types": ["node"]
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@vue/tsconfig/tsconfig.web.json",
3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
4 | "compilerOptions": {
5 | "baseUrl": ".",
6 | "paths": {
7 | "@/*": ["./src/*"]
8 | },
9 | "types": [
10 | "@types/webrtc"
11 | ]
12 | },
13 |
14 | "references": [
15 | {
16 | "path": "./tsconfig.config.json"
17 | }
18 | ]
19 | }
20 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { fileURLToPath, URL } from "node:url";
2 |
3 | import { defineConfig } from "vite";
4 | import vue from "@vitejs/plugin-vue";
5 |
6 | // https://vitejs.dev/config/
7 | export default defineConfig({
8 | plugins: [vue()],
9 | resolve: {
10 | alias: {
11 | "@": fileURLToPath(new URL("./src", import.meta.url)),
12 | },
13 | },
14 | });
15 |
--------------------------------------------------------------------------------