feat: WS /t2/live — proxy Gemini Live API — 124/124 tests

This commit is contained in:
Hermann_Kitio 2026-04-17 03:39:21 +03:00
parent f08be960b0
commit 653fc3150e
8 changed files with 422 additions and 66 deletions

View file

@ -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:*)"
]
}
}

View file

@ -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
View file

@ -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",

View file

@ -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",

View file

@ -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)

View 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
View 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
View 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
}