mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-13 01:05:37 +02:00
⭐ feat: Update website, API, and infra (#164)
>Adds `maitred` in charge of handling automated game installs, updates,
and even execution.
>Not only that, we have the hosted stuff here
>- [x] AWS Task on ECS GPUs
>- [ ] Add a service to listen for game starts and stops
(docker-compose.yml)
>- [x] Add a queue for requesting a game to start
>- [x] Fix up the play/watch UI
>TODO:
>- Add a README
>- Add an SST docs
Edit:
- This adds a new landing page, updates the homepage etc etc
>I forgot what the rest of the updated stuff are 😅
This commit is contained in:
38
packages/functions/src/party/authorizer.ts
Normal file
38
packages/functions/src/party/authorizer.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Resource } from "sst";
|
||||
import { subjects } from "../subjects";
|
||||
import { realtime } from "sst/aws/realtime";
|
||||
import { createClient } from "@openauthjs/openauth/client";
|
||||
|
||||
export const handler = realtime.authorizer(async (token) => {
|
||||
//TODO: Use the following criteria for a topic - team-slug/container-id (container ids are not unique globally)
|
||||
//TODO: Allow the authorizer to subscriber/publisher to listen on - team-slug topics only (as the container will listen on the team-slug/container-id topic to be specific)
|
||||
// Return the topics to subscribe and publish
|
||||
|
||||
const client = createClient({
|
||||
clientID: "api",
|
||||
issuer: Resource.Urls.auth
|
||||
});
|
||||
|
||||
const result = await client.verify(subjects, token);
|
||||
|
||||
if (result.err) {
|
||||
console.log("error", result.err)
|
||||
return {
|
||||
subscribe: [],
|
||||
publish: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (result.subject.type != "device") {
|
||||
return {
|
||||
subscribe: [],
|
||||
publish: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
//It can publish and listen to other instances under this team
|
||||
subscribe: [`${Resource.App.name}/${Resource.App.stage}/${result.subject.properties.teamSlug}/*`],
|
||||
publish: [`${Resource.App.name}/${Resource.App.stage}/${result.subject.properties.teamSlug}/*`],
|
||||
};
|
||||
});
|
||||
64
packages/functions/src/party/create.ts
Normal file
64
packages/functions/src/party/create.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ECSClient, RunTaskCommand } from "@aws-sdk/client-ecs";
|
||||
const client = new ECSClient()
|
||||
|
||||
export const handler = async (event: any) => {
|
||||
console.log("event", event)
|
||||
const clusterArn = process.env.ECS_CLUSTER
|
||||
const taskDefinitionArn = process.env.TASK_DEFINITION
|
||||
const authFingerprintKey = process.env.AUTH_FINGERPRINT
|
||||
|
||||
try {
|
||||
|
||||
const runResponse = await client.send(new RunTaskCommand({
|
||||
taskDefinition: taskDefinitionArn,
|
||||
cluster: clusterArn,
|
||||
count: 1,
|
||||
launchType: "EC2",
|
||||
overrides: {
|
||||
containerOverrides: [
|
||||
{
|
||||
name: "nestri",
|
||||
environment: [
|
||||
{
|
||||
name: "AUTH_FINGERPRINT_KEY",
|
||||
value: authFingerprintKey
|
||||
},
|
||||
{
|
||||
name: "NESTRI_ROOM",
|
||||
value: "testing-right-now"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}))
|
||||
|
||||
// Check if tasks were started
|
||||
if (!runResponse.tasks || runResponse.tasks.length === 0) {
|
||||
throw new Error("No tasks were started");
|
||||
}
|
||||
|
||||
// Extract task details
|
||||
const task = runResponse.tasks[0];
|
||||
const taskArn = task.taskArn!;
|
||||
const taskId = taskArn.split('/').pop()!; // Extract task ID from ARN
|
||||
const taskStatus = task.lastStatus!;
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({
|
||||
status: "sent",
|
||||
taskId: taskId,
|
||||
taskStatus: taskStatus,
|
||||
taskArn: taskArn
|
||||
}, null, 2),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Error starting task:", err);
|
||||
return {
|
||||
statusCode: 500,
|
||||
body: JSON.stringify({ error: "Failed to start task" }, null, 2),
|
||||
};
|
||||
|
||||
}
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
import "zod-openapi/extend";
|
||||
import { Hono } from "hono";
|
||||
import { logger } from "hono/logger";
|
||||
import type { HonoBindings } from "./types";
|
||||
import { ApiSession } from "./session";
|
||||
import { openAPISpecs } from "hono-openapi";
|
||||
|
||||
const app = new Hono<{ Bindings: HonoBindings }>().basePath('/parties/main/:room');
|
||||
|
||||
app
|
||||
.use(logger(), async (c, next) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
try {
|
||||
await next();
|
||||
} catch (e: any) {
|
||||
return c.json(
|
||||
{
|
||||
error: {
|
||||
message: e.message || "Internal Server Error",
|
||||
status: e.status || 500,
|
||||
},
|
||||
},
|
||||
e.status || 500
|
||||
);
|
||||
}
|
||||
})
|
||||
|
||||
const routes = app
|
||||
.get("/health", (c) => {
|
||||
return c.json({
|
||||
status: "healthy",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
})
|
||||
.route("/session", ApiSession.route)
|
||||
|
||||
app.get(
|
||||
"/doc",
|
||||
openAPISpecs(routes, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: "Nestri Realtime API",
|
||||
description:
|
||||
"The Nestri realtime API gives you the power to connect to your remote machine and relays from a single station",
|
||||
version: "0.3.0",
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
Bearer: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "JWT",
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [{ Bearer: [] }],
|
||||
servers: [
|
||||
{ description: "Production", url: "https://api.nestri.io" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export type Routes = typeof routes;
|
||||
export default app
|
||||
@@ -1,63 +0,0 @@
|
||||
import app from "./hono"
|
||||
import type * as Party from "partykit/server";
|
||||
import { tryAuthentication } from "./utils";
|
||||
|
||||
export default class Server implements Party.Server {
|
||||
constructor(readonly room: Party.Room) { }
|
||||
|
||||
static async onBeforeRequest(req: Party.Request, lobby: Party.Lobby) {
|
||||
const docs = new URL(req.url).toString().endsWith("/doc")
|
||||
if (docs) {
|
||||
return req
|
||||
}
|
||||
|
||||
try {
|
||||
return await tryAuthentication(req, lobby)
|
||||
} catch (e: any) {
|
||||
// authentication failed!
|
||||
return new Response(e, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
static async onBeforeConnect(request: Party.Request, lobby: Party.Lobby) {
|
||||
try {
|
||||
return await tryAuthentication(request, lobby)
|
||||
} catch (e: any) {
|
||||
// authentication failed!
|
||||
return new Response(e, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
onRequest(req: Party.Request): Response | Promise<Response> {
|
||||
|
||||
return app.fetch(req as any, { room: this.room })
|
||||
}
|
||||
|
||||
getConnectionTags(conn: Party.Connection, ctx: Party.ConnectionContext) {
|
||||
|
||||
return [conn.id, ctx.request.cf?.country as any]
|
||||
}
|
||||
|
||||
onConnect(conn: Party.Connection, ctx: Party.ConnectionContext): void | Promise<void> {
|
||||
console.log(`Connected:, id:${conn.id}, room: ${this.room.id}, url: ${new URL(ctx.request.url).pathname}`);
|
||||
|
||||
this.getConnectionTags(conn, ctx)
|
||||
}
|
||||
|
||||
onMessage(message: string, sender: Party.Connection) {
|
||||
// let's log the message
|
||||
console.log(`connection ${sender.id} sent message: ${message}`);
|
||||
// console.log("tags", this.room.getConnections())
|
||||
// for (const british of this.room.getConnections(sender.id)) {
|
||||
// british.send(`Pip-pip!`);
|
||||
// }
|
||||
// // as well as broadcast it to all the other connections in the room...
|
||||
// this.room.broadcast(
|
||||
// `${sender.id}: ${message}`,
|
||||
// // ...except for the connection it came from
|
||||
// [sender.id]
|
||||
// );
|
||||
}
|
||||
}
|
||||
|
||||
Server satisfies Party.Worker;
|
||||
@@ -1,217 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { Hono } from "hono";
|
||||
import { Result } from "../common"
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import type { HonoBindings, WSMessage } from "./types";
|
||||
import { validator, resolver } from "hono-openapi/zod";
|
||||
|
||||
export module ApiSession {
|
||||
export const route = new Hono<{ Bindings: HonoBindings }>()
|
||||
.post("/:sessionID/start",
|
||||
describeRoute({
|
||||
tags: ["Session"],
|
||||
summary: "Start a session",
|
||||
description: "Start a session on this machine",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string(),
|
||||
sessionID: z.string()
|
||||
}))
|
||||
},
|
||||
},
|
||||
description: "Session started successfully",
|
||||
},
|
||||
500: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string(), details: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "There was a problem trying to start your session",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string().openapi({
|
||||
description: "The session ID to start",
|
||||
example: "18d8b4b5-29ba-4a62-8cf9-7059449907a7",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const param = c.req.valid("param");
|
||||
const room = c.env.room
|
||||
|
||||
const message: WSMessage = {
|
||||
type: "START_GAME",
|
||||
sessionID: param.sessionID,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
room.broadcast(JSON.stringify(message));
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
message: "Game start signal sent",
|
||||
"sessionID": param.sessionID,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
return c.json(
|
||||
{
|
||||
error: {
|
||||
message: "Failed to start game session",
|
||||
details: error.message,
|
||||
},
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
)
|
||||
.post("/:sessionID/end",
|
||||
describeRoute({
|
||||
tags: ["Session"],
|
||||
summary: "End a session",
|
||||
description: "End a session on this machine",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string(),
|
||||
sessionID: z.string()
|
||||
}))
|
||||
},
|
||||
},
|
||||
description: "Session successfully ended",
|
||||
},
|
||||
500: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string(), details: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "There was a problem trying to end your session",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string().openapi({
|
||||
description: "The session ID to end",
|
||||
example: "18d8b4b5-29ba-4a62-8cf9-7059449907a7",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const param = c.req.valid("param");
|
||||
const room = c.env.room
|
||||
|
||||
const message: WSMessage = {
|
||||
type: "END_GAME",
|
||||
sessionID: param.sessionID,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
room.broadcast(JSON.stringify(message));
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
message: "Game end signal sent",
|
||||
"sessionID": param.sessionID,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
return c.json(
|
||||
{
|
||||
error: {
|
||||
message: "Failed to end game session",
|
||||
details: error.message,
|
||||
},
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
)
|
||||
.post("/:sessionID/status",
|
||||
describeRoute({
|
||||
tags: ["Session"],
|
||||
summary: "Get the status of a session",
|
||||
description: "Get the status of a session on this machine",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string(),
|
||||
sessionID: z.string()
|
||||
}))
|
||||
},
|
||||
},
|
||||
description: "Session status query was successful"
|
||||
},
|
||||
500: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string(), details: z.string() })),
|
||||
},
|
||||
},
|
||||
description: "There was a problem trying to querying the status of your session",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string().openapi({
|
||||
description: "The session ID to query",
|
||||
example: "18d8b4b5-29ba-4a62-8cf9-7059449907a7",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const param = c.req.valid("param");
|
||||
const room = c.env.room
|
||||
|
||||
const message: WSMessage = {
|
||||
type: "END_GAME",
|
||||
sessionID: param.sessionID,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
room.broadcast(JSON.stringify(message));
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
message: "Game end signal sent",
|
||||
"sessionID": param.sessionID,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
return c.json(
|
||||
{
|
||||
error: {
|
||||
message: "Failed to end game session",
|
||||
details: error.message,
|
||||
},
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
4
packages/functions/src/party/subscriber.ts
Normal file
4
packages/functions/src/party/subscriber.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const handler = async (event: any) => {
|
||||
console.log(event);
|
||||
return "ok";
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
import type * as Party from "partykit/server";
|
||||
|
||||
export interface HonoBindings {
|
||||
room: Party.Room;
|
||||
}
|
||||
|
||||
export type WSMessage = {
|
||||
type: "START_GAME" | "END_GAME" | "GAME_STATUS";
|
||||
sessionID: string;
|
||||
payload?: any;
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import type * as Party from "partykit/server";
|
||||
|
||||
export async function tryAuthentication(req: Party.Request, lobby: Party.Lobby) {
|
||||
const authHeader = req.headers.get("authorization") ?? new URL(req.url).searchParams.get("authorization")
|
||||
if (authHeader) {
|
||||
const match = authHeader.match(/^Bearer (.+)$/);
|
||||
|
||||
if (!match || !match[1]) {
|
||||
throw new Error("Bearer token not found or improperly formatted");
|
||||
}
|
||||
|
||||
const bearerToken = match[1];
|
||||
|
||||
if (bearerToken !== lobby.env.AUTH_FINGERPRINT) {
|
||||
throw new Error("Invalid authorization token");
|
||||
}
|
||||
|
||||
return req// app.fetch(req as any, { room: this.room })
|
||||
}
|
||||
throw new Error("You are not authorized to be here")
|
||||
}
|
||||
Reference in New Issue
Block a user