diff --git a/eslint.config.js b/eslint.config.js index 7ade9d8..ea47a7d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,6 +8,7 @@ export default antfu( ignores: [ '**/assets/js/**', '**/assets/live2d/models/**', + 'packages/stage-tamagotchi/out/**', ], }, ) diff --git a/packages/server-runtime/src/index.ts b/packages/server-runtime/src/index.ts index c186e78..ee42120 100644 --- a/packages/server-runtime/src/index.ts +++ b/packages/server-runtime/src/index.ts @@ -1,49 +1,81 @@ import type { WebSocketEvent } from '@proj-airi/server-shared/types' import type { Peer } from 'crossws' +import type { AuthenticatedPeer } from './types' +import { env } from 'node:process' import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg' import { createApp, createRouter, defineWebSocketHandler } from 'h3' setGlobalFormat(Format.Pretty) setGlobalLogLevel(LogLevel.Log) -const appLogger = useLogg('App').useGlobalConfig() -const websocketLogger = useLogg('WebSocket').useGlobalConfig() +function send(peer: Peer, event: WebSocketEvent) { + peer.send(JSON.stringify(event)) +} -export const app = createApp({ - onError: error => appLogger.withError(error).error('an error occurred'), -}) +function main() { + const appLogger = useLogg('App').useGlobalConfig() + const websocketLogger = useLogg('WebSocket').useGlobalConfig() -const router = createRouter() -app.use(router) + const app = createApp({ + onError: error => appLogger.withError(error).error('an error occurred'), + }) -const peers = new Set() + const router = createRouter() + app.use(router) -router.get('/ws', defineWebSocketHandler({ - open: (peer) => { - peers.add(peer) - websocketLogger.withFields({ peer: peer.id, activePeers: peers.size }).log('connected') - }, - message: (peer, message) => { - const event = message.json() as WebSocketEvent + const peers = new Map() - switch (event.type) { - case 'input:text': - break - case 'input:text:voice': - break - } + router.get('/ws', defineWebSocketHandler({ + open: (peer) => { + const token = env.AUTHENTICATION_TOKEN || '' + if (token) { + peers.set(peer.id, { peer, authenticated: false }) + } + else { + send(peer, { type: 'module:authenticated', data: { authenticated: true } }) + peers.set(peer.id, { peer, authenticated: true }) + } + + websocketLogger.withFields({ peer: peer.id, activePeers: peers.size }).log('connected') + }, + message: (peer, message) => { + const token = env.AUTHENTICATION_TOKEN || '' + const event = message.json() as WebSocketEvent - for (const p of peers) { - if (p.id !== peer.id) { - p.send(JSON.stringify(event)) + switch (event.type) { + case 'module:authenticate': + if (!!token && event.data.token !== token) { + websocketLogger.withFields({ peer: peer.id }).debug('authentication failed') + send(peer, { type: 'error', data: { message: 'invalid token' } }) + return + } + + send(peer, { type: 'module:authenticated', data: { authenticated: true } }) + peers.set(peer.id, { peer, authenticated: true }) + return + } + if (!peers.get(peer.id)?.authenticated) { + websocketLogger.withFields({ peer: peer.id }).debug('not authenticated') + send(peer, { type: 'error', data: { message: 'not authenticated' } }) + return } - } - }, - error: (peer, error) => { - websocketLogger.withFields({ peer: peer.id }).withError(error).error('an error occurred') - }, - close: (peer, details) => { - websocketLogger.withFields({ peer: peer.id, details, activePeers: peers.size }).log('closed') - peers.delete(peer) - }, -})) + + for (const [id, p] of peers.entries()) { + if (id !== peer.id) { + p.peer.send(JSON.stringify(event)) + } + } + }, + error: (peer, error) => { + websocketLogger.withFields({ peer: peer.id }).withError(error).error('an error occurred') + }, + close: (peer, details) => { + websocketLogger.withFields({ peer: peer.id, details, activePeers: peers.size }).log('closed') + peers.delete(peer.id) + }, + })) + + return app +} + +export const app = main() diff --git a/packages/server-runtime/src/types/conn.ts b/packages/server-runtime/src/types/conn.ts new file mode 100644 index 0000000..2e1cd3e --- /dev/null +++ b/packages/server-runtime/src/types/conn.ts @@ -0,0 +1,6 @@ +import type { Peer } from 'crossws' + +export interface AuthenticatedPeer { + peer: Peer + authenticated: boolean +} diff --git a/packages/server-runtime/src/types/index.ts b/packages/server-runtime/src/types/index.ts new file mode 100644 index 0000000..91af981 --- /dev/null +++ b/packages/server-runtime/src/types/index.ts @@ -0,0 +1 @@ +export * from './conn' diff --git a/packages/server-sdk/src/client.ts b/packages/server-sdk/src/client.ts index 9c215aa..3040b25 100644 --- a/packages/server-sdk/src/client.ts +++ b/packages/server-sdk/src/client.ts @@ -2,30 +2,77 @@ import type { WebSocketBaseEvent, WebSocketEvent, WebSocketEvents } from '@proj- import type { Blob } from 'node:buffer' import WebSocket from 'crossws/websocket' import { defu } from 'defu' +import { sleep } from './utils' export interface ClientOptions { url?: string name: string possibleEvents?: Array<(keyof WebSocketEvents)> + token?: string } export class Client { + private opts: Required private websocket: WebSocket - private eventListeners: Map) => void | Promise>> = new Map() + private eventListeners: Map< + keyof WebSocketEvents, + Array<(data: WebSocketBaseEvent< + keyof WebSocketEvents, + WebSocketEvents[keyof WebSocketEvents] + >) => void | Promise> + > = new Map() + + private authenticateAttempts = 0 constructor(options: ClientOptions) { - const opts = defu, Required>[]>(options, { url: 'ws://localhost:6121/ws', possibleEvents: [] }) + const opts = defu, Required>[]>( + options, + { url: 'ws://localhost:6121/ws', possibleEvents: [] }, + ) this.websocket = new WebSocket(opts.url) + + this.onEvent('module:authenticated', async (event) => { + const auth = event.data.authenticated + if (!auth) { + this.authenticateAttempts++ + await sleep(2 ** this.authenticateAttempts * 1000) + this.tryAuthenticate() + } + else { + this.tryAnnounce() + } + }) + this.websocket.onmessage = this.handleMessage.bind(this) + this.websocket.onopen = () => { - this.send({ - type: 'module:announce', - data: { - name: opts.name, - possibleEvents: opts.possibleEvents, - }, - }) + if (opts.token) { + this.tryAuthenticate() + } + else { + this.tryAnnounce() + } + } + + this.websocket.onclose = () => { + this.authenticateAttempts = 0 + } + } + + private tryAnnounce() { + this.send({ + type: 'module:announce', + data: { + name: this.opts.name, + possibleEvents: this.opts.possibleEvents, + }, + }) + } + + private tryAuthenticate() { + if (this.opts.token) { + this.send({ type: 'module:authenticate', data: { token: this.opts.token || '' } }) } } @@ -35,12 +82,14 @@ export class Client { if (!listeners) return - for (const listener of listeners) { + for (const listener of listeners) await listener(data) - } } - onEvent(event: E, callback: (data: WebSocketBaseEvent) => void | Promise): void { + onEvent( + event: E, + callback: (data: WebSocketBaseEvent) => void | Promise, + ): void { if (!this.eventListeners.get(event)) { this.eventListeners.set(event, []) } diff --git a/packages/server-sdk/src/utils/concurrency.ts b/packages/server-sdk/src/utils/concurrency.ts new file mode 100644 index 0000000..3906817 --- /dev/null +++ b/packages/server-sdk/src/utils/concurrency.ts @@ -0,0 +1,3 @@ +export function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)) +} diff --git a/packages/server-sdk/src/utils/index.ts b/packages/server-sdk/src/utils/index.ts new file mode 100644 index 0000000..0ffd285 --- /dev/null +++ b/packages/server-sdk/src/utils/index.ts @@ -0,0 +1 @@ +export * from './concurrency' diff --git a/packages/server-shared/src/types/websocket/events.ts b/packages/server-shared/src/types/websocket/events.ts index 81100d1..1f82cb7 100644 --- a/packages/server-shared/src/types/websocket/events.ts +++ b/packages/server-shared/src/types/websocket/events.ts @@ -29,6 +29,15 @@ export type WithInputSource = { // A little hack for creating extensible discriminated unions : r/typescript // https://www.reddit.com/r/typescript/comments/1064ibt/a_little_hack_for_creating_extensible/ export interface WebSocketEvents { + 'error': { + message: string + } + 'module:authenticate': { + token: string + } + 'module:authenticated': { + authenticated: boolean + } 'module:announce': { name: string possibleEvents: Array<(keyof WebSocketEvents)>