mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 08:45:38 +02:00
✨ feat: Host a relay on Hetzner (#114)
We are hosting a [MoQ](https://quic.video) relay on a remote (bare metal) server on Hetzner With a lot of help from @victorpahuus
This commit is contained in:
@@ -32,7 +32,9 @@
|
||||
"@builder.io/qwik": "^1.8.0",
|
||||
"@builder.io/qwik-city": "^1.8.0",
|
||||
"@builder.io/qwik-react": "0.5.0",
|
||||
"@modular-forms/qwik": "^0.27.0",
|
||||
"@nestri/eslint-config": "*",
|
||||
"@nestri/moq": "*",
|
||||
"@nestri/typescript-config": "*",
|
||||
"@nestri/ui": "*",
|
||||
"@types/eslint": "8.56.10",
|
||||
@@ -48,6 +50,7 @@
|
||||
"react-dom": "18.2.0",
|
||||
"typescript": "5.4.5",
|
||||
"undici": "*",
|
||||
"valibot": "^0.42.1",
|
||||
"vite": "5.3.5",
|
||||
"vite-tsconfig-paths": "^4.2.1",
|
||||
"wrangler": "^3.0.0"
|
||||
|
||||
118
apps/www/src/routes/(moq)/moq/checker/index.tsx
Normal file
118
apps/www/src/routes/(moq)/moq/checker/index.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import * as v from "valibot"
|
||||
import { Broadcast } from "./tester";
|
||||
import { cn } from "@nestri/ui/design";
|
||||
import { routeLoader$ } from "@builder.io/qwik-city";
|
||||
import { component$, $, useSignal } from "@builder.io/qwik";
|
||||
import { MotionComponent, transition, TitleSection, Button } from "@nestri/ui/react";
|
||||
import { type InitialValues, type SubmitHandler, useForm, valiForm$ } from "@modular-forms/qwik"
|
||||
|
||||
const Schema = v.object({
|
||||
url: v.pipe(
|
||||
v.string(),
|
||||
v.minLength(10, "Please input a valid url"),
|
||||
v.url("Please input a valid url"),
|
||||
)
|
||||
}, "Please fill in all the fields correctly.")
|
||||
|
||||
type Form = v.InferInput<typeof Schema>;
|
||||
|
||||
export const useFormLoader = routeLoader$<InitialValues<Form>>(async () => {
|
||||
return {
|
||||
url: ""
|
||||
}
|
||||
})
|
||||
|
||||
const generateRandomWord = (length: number) => {
|
||||
const characters = 'abcdefghijklmnopqrstuvwxyz';
|
||||
return Array.from({ length }, () => characters[Math.floor(Math.random() * characters.length)]).join('');
|
||||
};
|
||||
|
||||
export default component$(() => {
|
||||
const broadcasterOk = useSignal<boolean | undefined>();
|
||||
const [state, { Form, Field }] = useForm<Form>({
|
||||
loader: useFormLoader(),
|
||||
validate: valiForm$(Schema)
|
||||
});
|
||||
|
||||
const handleSubmit = $<SubmitHandler<Form>>(async (values) => {
|
||||
const randomNamespace = generateRandomWord(6);
|
||||
const sub = await Broadcast.init({ url: values.url, fingerprint: undefined, namespace: randomNamespace })
|
||||
|
||||
setTimeout(() => {
|
||||
broadcasterOk.value = sub.isSubscribed()
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<TitleSection client:load title="MoQ Checker" description="Test the connection to your Media-Over-Quic relay!" />
|
||||
<MotionComponent
|
||||
initial={{ opacity: 0, y: 100 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={transition}
|
||||
client:load
|
||||
class="flex items-center justify-center w-full"
|
||||
as="div"
|
||||
>
|
||||
<section class="w-full flex flex-col gap-4 justify-center items-center">
|
||||
<Form onSubmit$={handleSubmit} class="w-full max-w-xl flex px-3 gap-2">
|
||||
<Field name="url">
|
||||
{(field, props) => {
|
||||
return (
|
||||
<div class="w-full flex flex-col gap-2">
|
||||
<div class="bg-gray-200 dark:bg-gray-800 flex rounded-lg w-full relative h-10 flex-none border focus-within:bg-gray-300/70 dark:focus-within:bg-gray-700/70 border-gray-300 dark:border-gray-700 ">
|
||||
<input type="url" class={cn("w-full relative h-full bg-transparent rounded-lg p-3 focus-within:outline-none focus-within:ring-2 focus-within:ring-gray-400 dark:focus-within:ring-gray-600 focus-within:ring-offset-2 focus-visible:outline-none focus-within:ring-offset-gray-100 dark:focus-within:ring-offset-gray-900 placeholder:text-gray-500/70", typeof broadcasterOk.value !== "undefined" && broadcasterOk.value == true && "ring-2 ring-offset-2 ring-offset-gray-100 dark:ring-offset-gray-900 ring-green-500", typeof broadcasterOk.value !== "undefined" && (broadcasterOk.value == false) && "ring-2 ring-offset-2 ring-offset-gray-100 dark:ring-offset-gray-900 ring-red-500")} placeholder="https://relay.domain.com" {...props} />
|
||||
</div>
|
||||
{field.error && (<p class='text-[0.8rem] font-medium text-danger-600 dark:text-danger-500' >{field.error}</p>)}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Field>
|
||||
|
||||
{/* <button class={cn(buttonVariants.solid({ size: "md", intent: "neutral" }), "w-max space-y-0 relative")} style={{ height: 40, marginTop: 0 }} type="submit" >
|
||||
Check
|
||||
</button> */}
|
||||
<Button.Root
|
||||
disabled={state.submitting}
|
||||
isLoading={state.submitting}
|
||||
// setIsLoading={setIsLoading}
|
||||
client:load
|
||||
//@ts-ignore
|
||||
type="submit"
|
||||
style={{ height: 40, marginTop: 0 }}
|
||||
intent="neutral"
|
||||
size="md"
|
||||
class="w-max space-y-0 relative">
|
||||
{/* <Button.Icon
|
||||
isLoading={isLoading.value}
|
||||
client:load>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 16 16">
|
||||
<g fill="currentColor">
|
||||
<path d="M.329 10.333A8.01 8.01 0 0 0 7.99 16C12.414 16 16 12.418 16 8s-3.586-8-8.009-8A8.006 8.006 0 0 0 0 7.468l.003.006l4.304 1.769A2.2 2.2 0 0 1 5.62 8.88l1.96-2.844l-.001-.04a3.046 3.046 0 0 1 3.042-3.043a3.046 3.046 0 0 1 3.042 3.043a3.047 3.047 0 0 1-3.111 3.044l-2.804 2a2.223 2.223 0 0 1-3.075 2.11a2.22 2.22 0 0 1-1.312-1.568L.33 10.333Z" /><path d="M4.868 12.683a1.715 1.715 0 0 0 1.318-3.165a1.7 1.7 0 0 0-1.263-.02l1.023.424a1.261 1.261 0 1 1-.97 2.33l-.99-.41a1.7 1.7 0 0 0 .882.84Zm3.726-6.687a2.03 2.03 0 0 0 2.027 2.029a2.03 2.03 0 0 0 2.027-2.029a2.03 2.03 0 0 0-2.027-2.027a2.03 2.03 0 0 0-2.027 2.027m2.03-1.527a1.524 1.524 0 1 1-.002 3.048a1.524 1.524 0 0 1 .002-3.048" />
|
||||
</g>
|
||||
</svg>
|
||||
</Button.Icon> */}
|
||||
<Button.Label
|
||||
loadingText="Checking..."
|
||||
class="text-ellipsis whitespace-nowrap"
|
||||
isLoading={state.submitting}>
|
||||
Check
|
||||
</Button.Label>
|
||||
<div class="w-[8%]" />
|
||||
</Button.Root>
|
||||
</Form>
|
||||
{typeof broadcasterOk.value !== "undefined" && broadcasterOk.value == true ? (
|
||||
<span class="w-full text-green-500 max-w-xl flex space-y-6 px-3 gap-2">
|
||||
Your relay is doing okay
|
||||
</span>
|
||||
) : typeof broadcasterOk.value !== "undefined" && (
|
||||
<span class="w-full text-red-500 max-w-xl flex space-y-6 px-3 gap-2">
|
||||
Your relay has an issue
|
||||
</span>
|
||||
)}
|
||||
</section>
|
||||
</MotionComponent>
|
||||
</>
|
||||
)
|
||||
})
|
||||
208
apps/www/src/routes/(moq)/moq/checker/tester.ts
Normal file
208
apps/www/src/routes/(moq)/moq/checker/tester.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { Connection, SubscribeRecv } from "@nestri/moq/transport"
|
||||
import { asError } from "@nestri/moq/common/error"
|
||||
import { Client } from "@nestri/moq/transport/client"
|
||||
import * as Catalog from "@nestri/moq/media/catalog"
|
||||
import { type GroupWriter } from "@nestri/moq/transport/objects"
|
||||
|
||||
export interface BroadcastConfig {
|
||||
namespace: string
|
||||
connection: Connection
|
||||
}
|
||||
export interface BroadcasterConfig {
|
||||
url: string
|
||||
namespace: string
|
||||
fingerprint?: string // URL to fetch TLS certificate fingerprint
|
||||
}
|
||||
|
||||
export interface BroadcastConfigTrack {
|
||||
input: string
|
||||
bitrate: number
|
||||
}
|
||||
|
||||
export class Broadcast {
|
||||
stream: GroupWriter | null
|
||||
subscriber: SubscribeRecv | null
|
||||
subscribed: boolean;
|
||||
|
||||
|
||||
readonly config: BroadcastConfig
|
||||
readonly catalog: Catalog.Root
|
||||
readonly connection: Connection
|
||||
readonly namespace: string
|
||||
|
||||
#running: Promise<void>
|
||||
|
||||
constructor(config: BroadcastConfig) {
|
||||
this.subscribed = false
|
||||
this.namespace = config.namespace
|
||||
this.connection = config.connection
|
||||
this.config = config
|
||||
//Arbitrary values, just to keep TypeScript happy :)
|
||||
this.catalog = {
|
||||
version: 1,
|
||||
streamingFormat: 1,
|
||||
streamingFormatVersion: "0.2",
|
||||
supportsDeltaUpdates: false,
|
||||
commonTrackFields: {
|
||||
packaging: "loc",
|
||||
renderGroup: 1,
|
||||
},
|
||||
tracks: [{
|
||||
name: "tester",
|
||||
namespace: "tester",
|
||||
selectionParams: {}
|
||||
}],
|
||||
}
|
||||
this.stream = null
|
||||
this.subscriber = null
|
||||
|
||||
this.#running = this.#run()
|
||||
}
|
||||
|
||||
static async init(config: BroadcasterConfig): Promise<Broadcast> {
|
||||
const client = new Client({ url: config.url, fingerprint: config.fingerprint, role: "publisher" })
|
||||
const connection = await client.connect();
|
||||
|
||||
return new Broadcast({ connection, namespace: config.namespace })
|
||||
}
|
||||
|
||||
async #run() {
|
||||
try {
|
||||
await this.connection.announce(this.namespace)
|
||||
this.subscribed = true
|
||||
} catch (error) {
|
||||
|
||||
this.subscribed = false
|
||||
}
|
||||
|
||||
for (; ;) {
|
||||
const subscriber = await this.connection.subscribed()
|
||||
|
||||
if (!subscriber) {
|
||||
this.subscribed = false
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
await subscriber.ack()
|
||||
|
||||
this.subscriber = subscriber
|
||||
|
||||
this.subscribed = true
|
||||
|
||||
const bytes = Catalog.encode(this.catalog);
|
||||
|
||||
const stream = await subscriber.group({ group: 0 });
|
||||
|
||||
await stream.write({ object: 0, payload: bytes })
|
||||
|
||||
this.stream = stream
|
||||
}
|
||||
}
|
||||
|
||||
isSubscribed(): boolean {
|
||||
return this.subscribed;
|
||||
}
|
||||
|
||||
// async #serveSubscribe(subscriber: SubscribeRecv) {
|
||||
// try {
|
||||
|
||||
// // Send a SUBSCRIBE_OK
|
||||
// await subscriber.ack()
|
||||
|
||||
// console.log("catalog track name:", subscriber.track)
|
||||
|
||||
// const stream = await subscriber.group({ group: 0 });
|
||||
|
||||
// // const bytes = this.catalog.encode("Hello World")
|
||||
|
||||
// await stream.write({ object: 0, payload: bytes })
|
||||
|
||||
|
||||
|
||||
// } catch (e) {
|
||||
// const err = asError(e)
|
||||
// await subscriber.close(1n, `failed to process publish: ${err.message}`)
|
||||
// } finally {
|
||||
// // TODO we can't close subscribers because there's no support for clean termination
|
||||
// // await subscriber.close()
|
||||
// }
|
||||
// }
|
||||
|
||||
// async mouseUpdatePosition({ x, y }: { x: number, y: number }, stream: GroupWriter) {
|
||||
|
||||
// const mouse_move = {
|
||||
// input_type: "mouse_move",
|
||||
// delta_y: y,
|
||||
// delta_x: x,
|
||||
// }
|
||||
|
||||
// const bytes = Catalog.encode(this.catalog)
|
||||
|
||||
// await stream.write({ object: 0, payload: bytes });
|
||||
// }
|
||||
|
||||
// async mouseUpdateButtons(e: MouseEvent, stream: GroupWriter) {
|
||||
// const data: { input_type?: "mouse_key_down" | "mouse_key_up"; button: number; } = { button: e.button };
|
||||
|
||||
// if (e.type === "mousedown") {
|
||||
// data["input_type"] = "mouse_key_down"
|
||||
// } else if (e.type === "mouseup") {
|
||||
// data["input_type"] = "mouse_key_up"
|
||||
// }
|
||||
|
||||
// const bytes = Catalog.encode(this.catalog)
|
||||
|
||||
// await stream.write({ object: 0, payload: bytes });
|
||||
// }
|
||||
|
||||
// async mouseUpdateWheel(e: WheelEvent, stream: GroupWriter) {
|
||||
// const data: { input_type?: "mouse_wheel_up" | "mouse_wheel_down" } = {}
|
||||
|
||||
// if (e.deltaY < 0.0) {
|
||||
// data["input_type"] = "mouse_wheel_up"
|
||||
// } else {
|
||||
// data["input_type"] = "mouse_wheel_down"
|
||||
// }
|
||||
|
||||
// const bytes = Catalog.encode(this.catalog)
|
||||
|
||||
// await stream.write({ object: 0, payload: bytes });
|
||||
// }
|
||||
|
||||
// async updateKeyUp(e: KeyboardEvent, stream: GroupWriter) {
|
||||
// const data = {
|
||||
// input_type: "key_up",
|
||||
// key_code: e.keyCode
|
||||
// }
|
||||
|
||||
// const bytes = Catalog.encode(this.catalog)
|
||||
|
||||
// await stream.write({ object: 0, payload: bytes });
|
||||
// }
|
||||
|
||||
// async updateKeyDown(e: KeyboardEvent, stream: GroupWriter) {
|
||||
// const data = {
|
||||
// input_type: "key_down",
|
||||
// key_code: e.keyCode
|
||||
// }
|
||||
|
||||
// const bytes = Catalog.encode(this.catalog)
|
||||
|
||||
// await stream.write({ object: 0, payload: bytes });
|
||||
// }
|
||||
|
||||
close() {
|
||||
// TODO implement publish close
|
||||
}
|
||||
|
||||
// Returns the error message when the connection is closed
|
||||
async closed(): Promise<Error> {
|
||||
try {
|
||||
await this.#running
|
||||
return new Error("closed") // clean termination
|
||||
} catch (e) {
|
||||
return asError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "@nestri/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"target": "ES2017",
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"lib": [
|
||||
"es2022",
|
||||
|
||||
@@ -27,6 +27,17 @@ export default defineConfig((): UserConfig => {
|
||||
qwikVite(),
|
||||
tsconfigPaths(),
|
||||
qwikReact(),
|
||||
//For Moq-js (SharedArrayBuffer)
|
||||
{
|
||||
name: "configure-response-headers",
|
||||
configureServer: (server) => {
|
||||
server.middlewares.use((_req, res, next) => {
|
||||
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
|
||||
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
||||
next();
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
// This tells Vite which dependencies to pre-build in dev mode.
|
||||
optimizeDeps: {
|
||||
@@ -51,6 +62,8 @@ export default defineConfig((): UserConfig => {
|
||||
// }
|
||||
// : undefined,
|
||||
server: {
|
||||
// https: true,
|
||||
// proxy:{},
|
||||
headers: {
|
||||
// Don't cache the server response in dev mode
|
||||
"Cache-Control": "public, max-age=0",
|
||||
|
||||
Reference in New Issue
Block a user