feat: WS /t2/live — proxy Gemini Live API — 124/124 tests
This commit is contained in:
parent
f08be960b0
commit
653fc3150e
8 changed files with 422 additions and 66 deletions
|
|
@ -2,7 +2,8 @@
|
|||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run:*)",
|
||||
"Bash(SUPABASE_URL=https://dummy.supabase.co SUPABASE_SERVICE_ROLE_KEY=dummy node -e \"import\\('./dist/index.js'\\).then\\(\\(\\) => { console.log\\('IMPORT_OK'\\); process.exit\\(0\\); }\\).catch\\(e => { console.error\\('IMPORT_FAIL:', e.message\\); process.exit\\(1\\); }\\)\")"
|
||||
"Bash(SUPABASE_URL=https://dummy.supabase.co SUPABASE_SERVICE_ROLE_KEY=dummy node -e \"import\\('./dist/index.js'\\).then\\(\\(\\) => { console.log\\('IMPORT_OK'\\); process.exit\\(0\\); }\\).catch\\(e => { console.error\\('IMPORT_FAIL:', e.message\\); process.exit\\(1\\); }\\)\")",
|
||||
"Bash(npm install:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# ARCHITECTURE.md — Expria / Coach TCF Canada
|
||||
|
||||
> **Document de référence — Version 1.1**
|
||||
> **Document de référence — Version 1.2**
|
||||
> Ce document décrit l'architecture technique complète du projet.
|
||||
> Toute décision architecturale majeure doit être documentée ici avant d'être implémentée.
|
||||
> À lire conjointement avec PLANS_TARIFAIRES.md et PARCOURS_UTILISATEURS.md.
|
||||
|
|
@ -424,35 +424,17 @@ NODE_ENV=production
|
|||
|
||||
## 9. Déploiement
|
||||
|
||||
### Contexte — Contrainte d'hébergement Git
|
||||
### Hébergement Git — GitHub
|
||||
|
||||
GitHub et GitLab appliquent les sanctions américaines OFAC qui restreignent l'accès
|
||||
aux résidents de Crimée. Ces plateformes ne sont pas utilisables de façon fiable
|
||||
pour ce projet.
|
||||
|
||||
**Solution actuelle (Phase 1 — MVP) :**
|
||||
Codeberg (plateforme européenne, Allemagne) pour l'hébergement Git privé.
|
||||
Render ne supporte pas l'auto-deploy depuis Codeberg — le déploiement est donc manuel.
|
||||
|
||||
**Évolution prévue (Phase 2 — après premiers revenus) :**
|
||||
Migration vers un VPS Hetzner (3,29€/mois) avec Coolify.
|
||||
Coolify supporte l'auto-deploy depuis n'importe quel serveur Git privé (Codeberg, Gitea).
|
||||
Cette migration supprime la dépendance à Render et restaure l'auto-deploy complet.
|
||||
|
||||
---
|
||||
|
||||
### Hébergement Git — Codeberg (Phase 1)
|
||||
|
||||
- Plateforme : codeberg.org (Allemagne — hors juridiction américaine)
|
||||
- Dépôts : privés
|
||||
- Dépôt frontend : `https://codeberg.org/Hermann_Kitio/expria-frontend`
|
||||
- Dépôt backend : `https://codeberg.org/Hermann_Kitio/expria-backend`
|
||||
- Dépôt archive (ancienne version) : `https://codeberg.org/Hermann_Kitio/Expria`
|
||||
- Limitation : pas d'auto-deploy natif vers Render
|
||||
- Plateforme : github.com
|
||||
- Dépôt frontend : `https://github.com/germannoff/expria-frontend`
|
||||
- Dépôt backend : `https://github.com/germannoff/expria-backend`
|
||||
- Note : compte GitHub réactivé le 17 avril 2026 après restriction OFAC levée
|
||||
- Auto-deploy : disponible via Render (connecté à GitHub)
|
||||
|
||||
### Frontend — Cloudflare Pages
|
||||
|
||||
- Source : dépôt Codeberg `expria-frontend`
|
||||
- Source : dépôt GitHub `expria-frontend`
|
||||
- Build command : `npm run build`
|
||||
- Output directory : `dist`
|
||||
- Domaine : `expria.app` (DNS pointé depuis Vercel vers Cloudflare Pages)
|
||||
|
|
@ -466,23 +448,15 @@ npx wrangler pages deploy dist --project-name=expria
|
|||
|
||||
### Backend — Render
|
||||
|
||||
- Source : dépôt Codeberg `expria-backend`
|
||||
- Source : dépôt GitHub `expria-backend`
|
||||
- Type : Web Service (Node.js)
|
||||
- Région : Frankfurt (EU) — proximité utilisateurs Afrique du Nord
|
||||
- Build command : `npm run build`
|
||||
- Start command : `npm start`
|
||||
- Domaine : `api.expria.app`
|
||||
- Domaine : `api.expria.app` (certificat SSL actif)
|
||||
- URL Render : `https://expria-backend.onrender.com` (alias)
|
||||
- WebSocket : activé nativement sur Render
|
||||
- Déploiement : **manuel via Render CLI ou dashboard**
|
||||
|
||||
```bash
|
||||
# Commande de déploiement backend
|
||||
# Option 1 : via Render CLI
|
||||
render deploy
|
||||
|
||||
# Option 2 : via dashboard Render
|
||||
# → Manual Deploy → Deploy latest commit
|
||||
```
|
||||
- Déploiement : **automatique à chaque push sur main (GitHub → Render)**
|
||||
|
||||
### Base de données — Supabase
|
||||
|
||||
|
|
@ -490,39 +464,18 @@ render deploy
|
|||
- Migrations : versionnées dans `supabase/migrations/`
|
||||
- Déploiement : `supabase db push` (manuel, après validation)
|
||||
|
||||
### Procédure de déploiement complète (Phase 1)
|
||||
### Procédure de déploiement complète
|
||||
|
||||
```
|
||||
1. Tester localement (npm run test — tous les tests verts)
|
||||
2. Rejouer le Golden Dataset
|
||||
3. Commit + push sur Codeberg (branche main)
|
||||
4. Déployer le backend : render deploy (ou dashboard Render)
|
||||
3. Commit + push sur GitHub (branche main)
|
||||
4. Backend : auto-deploy Render déclenché automatiquement
|
||||
5. Déployer le frontend : npm run build && npx wrangler pages deploy dist
|
||||
6. Vérifier les URLs de production (expria.app + api.expria.app)
|
||||
7. Rejouer le Smoke Test (Groupe Z du Golden Dataset)
|
||||
```
|
||||
|
||||
### Évolution Phase 2 — VPS Hetzner + Coolify
|
||||
|
||||
Quand Expria génère ses premiers revenus, migrer vers :
|
||||
|
||||
```
|
||||
Codeberg (Git privé — inchangé)
|
||||
↓ auto-deploy via webhook
|
||||
Coolify sur VPS Hetzner CAX11 (3,29€/mois)
|
||||
— remplace Render pour le backend
|
||||
— auto-deploy natif depuis Codeberg
|
||||
— Docker, SSL automatique, logs intégrés
|
||||
↓
|
||||
Supabase (inchangé)
|
||||
```
|
||||
|
||||
Avantages de la Phase 2 :
|
||||
- Auto-deploy restauré (push → déploiement automatique)
|
||||
- Coût réduit (3,29€/mois vs Render Starter)
|
||||
- Aucune dépendance à une plateforme américaine pour le backend
|
||||
- Cloudflare Pages reste pour le frontend (gratuit, CDN mondial)
|
||||
|
||||
---
|
||||
|
||||
## 10. Règles de développement
|
||||
|
|
|
|||
21
package-lock.json
generated
21
package-lock.json
generated
|
|
@ -9,13 +9,16 @@
|
|||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@hono/node-ws": "^1.3.0",
|
||||
"@supabase/supabase-js": "^2.49.4",
|
||||
"dotenv": "^17.4.2",
|
||||
"hono": "^4.7.7",
|
||||
"stripe": "^17.7.0"
|
||||
"stripe": "^17.7.0",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.3",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^3.1.2",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.3",
|
||||
|
|
@ -550,6 +553,22 @@
|
|||
"hono": "^4"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-ws": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-ws/-/node-ws-1.3.0.tgz",
|
||||
"integrity": "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ws": "^8.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@hono/node-server": "^1.19.2",
|
||||
"hono": "^4.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -12,13 +12,16 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@hono/node-ws": "^1.3.0",
|
||||
"@supabase/supabase-js": "^2.49.4",
|
||||
"dotenv": "^17.4.2",
|
||||
"hono": "^4.7.7",
|
||||
"stripe": "^17.7.0"
|
||||
"stripe": "^17.7.0",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.3",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^3.1.2",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.3",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import 'dotenv/config'
|
||||
import { Hono } from 'hono'
|
||||
import { serve } from '@hono/node-server'
|
||||
import { createNodeWebSocket } from '@hono/node-ws'
|
||||
import authRoutes from './routes/auth.js'
|
||||
import plansRoutes from './routes/plans.js'
|
||||
import simulationsRoutes from './routes/simulations.js'
|
||||
import correctionsRoutes from './routes/corrections.js'
|
||||
import stripeRoutes from './routes/stripe.js'
|
||||
import createT2LiveRoutes from './routes/t2live.js'
|
||||
|
||||
const app = new Hono()
|
||||
const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app })
|
||||
|
||||
app.get('/', (c) => {
|
||||
return c.json({ message: 'Expria API — OK' }, 200)
|
||||
|
|
@ -18,9 +21,12 @@ app.route('/plans', plansRoutes)
|
|||
app.route('/simulations', simulationsRoutes)
|
||||
app.route('/corrections', correctionsRoutes)
|
||||
app.route('/stripe', stripeRoutes)
|
||||
app.route('/t2', createT2LiveRoutes(upgradeWebSocket))
|
||||
|
||||
const port = Number(process.env.PORT) || 3000
|
||||
|
||||
serve({ fetch: app.fetch, port }, () => {
|
||||
const server = serve({ fetch: app.fetch, port }, () => {
|
||||
console.log(`Expria API listening on port ${port}`)
|
||||
})
|
||||
|
||||
injectWebSocket(server)
|
||||
|
|
|
|||
134
src/lib/__tests__/geminiLive.test.ts
Normal file
134
src/lib/__tests__/geminiLive.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import {
|
||||
openGeminiLiveSession,
|
||||
T2_SYSTEM_PROMPT,
|
||||
type WebSocketLike,
|
||||
} from '../geminiLive'
|
||||
|
||||
class FakeWs extends EventEmitter implements WebSocketLike {
|
||||
public sent: unknown[] = []
|
||||
public closed = false
|
||||
public closeCode?: number
|
||||
public closeReason?: string
|
||||
|
||||
send(data: unknown): void {
|
||||
this.sent.push(data)
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string): void {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.closeCode = code
|
||||
this.closeReason = reason
|
||||
}
|
||||
}
|
||||
|
||||
describe('openGeminiLiveSession', () => {
|
||||
let originalKey: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
originalKey = process.env.GEMINI_API_KEY
|
||||
process.env.GEMINI_API_KEY = 'test-key'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalKey === undefined) {
|
||||
delete process.env.GEMINI_API_KEY
|
||||
} else {
|
||||
process.env.GEMINI_API_KEY = originalKey
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("envoie le setup frame avec T2_SYSTEM_PROMPT a l'open Gemini", () => {
|
||||
const client = new FakeWs()
|
||||
const gemini = new FakeWs()
|
||||
|
||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
||||
gemini.emit('open')
|
||||
|
||||
expect(gemini.sent).toHaveLength(1)
|
||||
const setup = JSON.parse(gemini.sent[0] as string)
|
||||
expect(setup.setup.model).toMatch(/gemini/)
|
||||
expect(setup.setup.system_instruction.parts[0].text).toBe(T2_SYSTEM_PROMPT)
|
||||
expect(setup.setup.generation_config.response_modalities).toContain('AUDIO')
|
||||
})
|
||||
|
||||
it('forwarde un message client (Buffer audio) vers Gemini', () => {
|
||||
const client = new FakeWs()
|
||||
const gemini = new FakeWs()
|
||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
||||
gemini.emit('open')
|
||||
|
||||
const audioChunk = Buffer.from([0x01, 0x02, 0x03, 0x04])
|
||||
client.emit('message', audioChunk)
|
||||
|
||||
// [0] = setup frame, [1] = audio forwarde
|
||||
expect(gemini.sent).toHaveLength(2)
|
||||
expect(gemini.sent[1]).toBe(audioChunk)
|
||||
})
|
||||
|
||||
it('forwarde un message Gemini vers le client', () => {
|
||||
const client = new FakeWs()
|
||||
const gemini = new FakeWs()
|
||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
||||
gemini.emit('open')
|
||||
|
||||
const examinerAudio = Buffer.from([0x10, 0x20])
|
||||
gemini.emit('message', examinerAudio)
|
||||
|
||||
expect(client.sent).toHaveLength(1)
|
||||
expect(client.sent[0]).toBe(examinerAudio)
|
||||
})
|
||||
|
||||
it('fermeture client → ferme Gemini avec code 1000', () => {
|
||||
const client = new FakeWs()
|
||||
const gemini = new FakeWs()
|
||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
||||
gemini.emit('open')
|
||||
|
||||
client.emit('close')
|
||||
|
||||
expect(gemini.closed).toBe(true)
|
||||
expect(gemini.closeCode).toBe(1000)
|
||||
})
|
||||
|
||||
it('fermeture Gemini → ferme client avec code 1000', () => {
|
||||
const client = new FakeWs()
|
||||
const gemini = new FakeWs()
|
||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
||||
gemini.emit('open')
|
||||
|
||||
gemini.emit('close')
|
||||
|
||||
expect(client.closed).toBe(true)
|
||||
expect(client.closeCode).toBe(1000)
|
||||
})
|
||||
|
||||
it('erreur Gemini → ferme client avec code 1011 GEMINI_ERROR', () => {
|
||||
const client = new FakeWs()
|
||||
const gemini = new FakeWs()
|
||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
||||
gemini.emit('open')
|
||||
|
||||
gemini.emit('error', new Error('boom'))
|
||||
|
||||
expect(client.closed).toBe(true)
|
||||
expect(client.closeCode).toBe(1011)
|
||||
expect(client.closeReason).toBe('GEMINI_ERROR')
|
||||
})
|
||||
|
||||
it("absence de GEMINI_API_KEY → close client 1011 CONFIG_ERROR sans appel a la factory", () => {
|
||||
delete process.env.GEMINI_API_KEY
|
||||
const client = new FakeWs()
|
||||
const factory = vi.fn(() => new FakeWs())
|
||||
|
||||
openGeminiLiveSession(client, { geminiFactory: factory })
|
||||
|
||||
expect(factory).not.toHaveBeenCalled()
|
||||
expect(client.closed).toBe(true)
|
||||
expect(client.closeCode).toBe(1011)
|
||||
expect(client.closeReason).toBe('CONFIG_ERROR')
|
||||
})
|
||||
})
|
||||
140
src/lib/geminiLive.ts
Normal file
140
src/lib/geminiLive.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { WebSocket as NodeWebSocket } from 'ws'
|
||||
|
||||
export const T2_SYSTEM_PROMPT = `Tu es un examinateur du TCF Canada pour l'épreuve d'Expression Orale, Tâche 2 (dialogue interactif).
|
||||
|
||||
RÔLE : Tu incarnes agent immobilier.
|
||||
CONTEXTE : Le candidat cherche un appartement à louer.
|
||||
|
||||
RÈGLES ABSOLUES :
|
||||
|
||||
1. Tu parles TOUJOURS en français naturel et courant, niveau B2-C1.
|
||||
2. Tu NE corriges JAMAIS les erreurs du candidat.
|
||||
3. Tu attends que le candidat finisse sa question avant de répondre.
|
||||
4. Tes réponses sont courtes (15 à 25 mots maximum).
|
||||
5. Ne donne pas toutes les informations d'un coup. Force le candidat à poser des questions précises.
|
||||
6. Si le candidat est vague, réponds de façon évasive pour le pousser à reformuler.
|
||||
7. Si le candidat reste silencieux, attends. Ne pose JAMAIS de question spontanée après tes réponses. C'est au candidat d'agir.
|
||||
8. En dernier recours uniquement (silence prolongé) : "Vous avez d'autres questions ?"
|
||||
9. Ne prends jamais d'initiatives : réponds uniquement aux questions posées.
|
||||
10. Tu peux être légèrement pressé ou hésitant pour rendre l'échange réaliste.
|
||||
11. JAMAIS de listes ni de structure numérotée dans tes réponses.
|
||||
12. Ne mentionne jamais que tu es une IA.
|
||||
|
||||
Commence l'exercice en te présentant brièvement dans ton rôle (1 phrase courte),
|
||||
puis attends que le candidat prenne l'initiative.`
|
||||
|
||||
export const GEMINI_LIVE_URL =
|
||||
'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent'
|
||||
|
||||
export const GEMINI_LIVE_MODEL = 'models/gemini-2.0-flash-exp'
|
||||
|
||||
/**
|
||||
* Subset minimal d'une WebSocket — compatible avec :
|
||||
* - le wrapper exposé par @hono/node-ws (côté client navigateur)
|
||||
* - la WebSocket de `ws` (côté Gemini)
|
||||
* - les fakes basés sur EventEmitter dans les tests
|
||||
*/
|
||||
export interface WebSocketLike {
|
||||
send(data: unknown): void
|
||||
close(code?: number, reason?: string): void
|
||||
on(event: 'message', listener: (data: unknown) => void): void
|
||||
on(event: 'close', listener: () => void): void
|
||||
on(event: 'error', listener: (err: unknown) => void): void
|
||||
on(event: 'open', listener: () => void): void
|
||||
}
|
||||
|
||||
export interface OpenGeminiLiveSessionOptions {
|
||||
/** Injection pour les tests — fabrique de WebSocket vers Gemini. */
|
||||
geminiFactory?: (url: string) => WebSocketLike
|
||||
/** Surcharge la clé API (par défaut : process.env.GEMINI_API_KEY). */
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
function buildSetupFrame(): string {
|
||||
return JSON.stringify({
|
||||
setup: {
|
||||
model: GEMINI_LIVE_MODEL,
|
||||
system_instruction: {
|
||||
parts: [{ text: T2_SYSTEM_PROMPT }],
|
||||
},
|
||||
generation_config: {
|
||||
response_modalities: ['AUDIO'],
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvre une session Gemini Live et proxifie les messages
|
||||
* dans les deux sens entre le client (navigateur) et Gemini.
|
||||
*
|
||||
* - À l'open Gemini : envoie le setup frame (modèle + system_instruction).
|
||||
* - Forward transparent des frames audio dans les deux directions.
|
||||
* - Fermeture coordonnée : close d'un côté → close de l'autre.
|
||||
* - Erreur Gemini → close client avec code 1011.
|
||||
* - Si GEMINI_API_KEY est absente : close client immédiat avec 1011.
|
||||
*/
|
||||
export function openGeminiLiveSession(
|
||||
clientWs: WebSocketLike,
|
||||
opts: OpenGeminiLiveSessionOptions = {}
|
||||
): void {
|
||||
const apiKey = opts.apiKey ?? process.env.GEMINI_API_KEY
|
||||
|
||||
if (!apiKey) {
|
||||
clientWs.close(1011, 'CONFIG_ERROR')
|
||||
return
|
||||
}
|
||||
|
||||
const url = `${GEMINI_LIVE_URL}?key=${apiKey}`
|
||||
const factory =
|
||||
opts.geminiFactory ??
|
||||
((u: string) => new NodeWebSocket(u) as unknown as WebSocketLike)
|
||||
|
||||
const geminiWs = factory(url)
|
||||
|
||||
let closed = false
|
||||
const closeBoth = (code = 1000, reason = '') => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
try {
|
||||
clientWs.close(code, reason)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
geminiWs.close(code, reason)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
geminiWs.on('open', () => {
|
||||
try {
|
||||
geminiWs.send(buildSetupFrame())
|
||||
} catch {
|
||||
closeBoth(1011, 'SETUP_FAILED')
|
||||
}
|
||||
})
|
||||
|
||||
geminiWs.on('message', (data) => {
|
||||
try {
|
||||
clientWs.send(data)
|
||||
} catch {
|
||||
closeBoth(1011, 'CLIENT_SEND_FAILED')
|
||||
}
|
||||
})
|
||||
|
||||
clientWs.on('message', (data) => {
|
||||
try {
|
||||
geminiWs.send(data)
|
||||
} catch {
|
||||
closeBoth(1011, 'GEMINI_SEND_FAILED')
|
||||
}
|
||||
})
|
||||
|
||||
geminiWs.on('close', () => closeBoth(1000))
|
||||
clientWs.on('close', () => closeBoth(1000))
|
||||
|
||||
geminiWs.on('error', () => closeBoth(1011, 'GEMINI_ERROR'))
|
||||
clientWs.on('error', () => closeBoth(1011, 'CLIENT_ERROR'))
|
||||
}
|
||||
100
src/routes/t2live.ts
Normal file
100
src/routes/t2live.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { Hono } from 'hono'
|
||||
import type { UpgradeWebSocket } from 'hono/ws'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { supabase } from '../lib/supabase.js'
|
||||
import { checkFeatureAccess } from '../lib/access.js'
|
||||
import type { Plan } from '../lib/access.js'
|
||||
import {
|
||||
openGeminiLiveSession,
|
||||
type WebSocketLike,
|
||||
} from '../lib/geminiLive.js'
|
||||
|
||||
/**
|
||||
* Crée le router pour `WS /t2/live`.
|
||||
* - Auth : JWT Supabase passé en query param `?token=<jwt>`
|
||||
* - Permission : plan Premium (`oral_t2_live`) via checkFeatureAccess
|
||||
* - Refus auth → close 4001, refus plan → close 4003
|
||||
* - OK → openGeminiLiveSession (proxy vers Gemini Live)
|
||||
*/
|
||||
export default function createT2LiveRoutes(
|
||||
upgradeWebSocket: UpgradeWebSocket
|
||||
) {
|
||||
const app = new Hono()
|
||||
|
||||
app.get(
|
||||
'/live',
|
||||
upgradeWebSocket(async (c) => {
|
||||
const token = c.req.query('token')
|
||||
let denyCode: number | null = null
|
||||
let denyReason = ''
|
||||
|
||||
if (!token) {
|
||||
denyCode = 4001
|
||||
denyReason = 'AUTH_REQUIRED'
|
||||
} else {
|
||||
try {
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser(token)
|
||||
|
||||
if (authError || !user) {
|
||||
denyCode = 4001
|
||||
denyReason = 'AUTH_REQUIRED'
|
||||
} else {
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.select('plan')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
if (profileError || !profile) {
|
||||
denyCode = 4001
|
||||
denyReason = 'AUTH_REQUIRED'
|
||||
} else if (
|
||||
!checkFeatureAccess(profile.plan as Plan, 'oral_t2_live')
|
||||
) {
|
||||
denyCode = 4003
|
||||
denyReason = 'PLAN_INSUFFICIENT'
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
denyCode = 4001
|
||||
denyReason = 'AUTH_REQUIRED'
|
||||
}
|
||||
}
|
||||
|
||||
// Adapter EventEmitter → WebSocketLike pour réutiliser openGeminiLiveSession
|
||||
const adapter = new EventEmitter() as EventEmitter & WebSocketLike
|
||||
adapter.send = () => {}
|
||||
adapter.close = () => {}
|
||||
|
||||
return {
|
||||
onOpen(_evt, ws) {
|
||||
adapter.send = (data: unknown) =>
|
||||
ws.send(data as Parameters<typeof ws.send>[0])
|
||||
adapter.close = (code?: number, reason?: string) =>
|
||||
ws.close(code, reason)
|
||||
|
||||
if (denyCode !== null) {
|
||||
ws.close(denyCode, denyReason)
|
||||
return
|
||||
}
|
||||
|
||||
openGeminiLiveSession(adapter)
|
||||
},
|
||||
onMessage(evt) {
|
||||
adapter.emit('message', evt.data)
|
||||
},
|
||||
onClose() {
|
||||
adapter.emit('close')
|
||||
},
|
||||
onError() {
|
||||
adapter.emit('error', new Error('CLIENT_ERROR'))
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return app
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue