@mostajs/media-server
v0.2.3
Published
Server-side factory + schema + service contract for @mostajs/media recordings — pairs with @mostajs/storage for blob persistence and an injected ORM repository for row metadata.
Downloads
302
Maintainers
Readme
@mostajs/media-server
Auteur : Dr Hamid MADANI [email protected] Licence : AGPL-3.0-or-later Statut : v0.1.0 — scaffold + factory + contract (P1 du design B.4)
Couche serveur du module @mostajs/media : factory
createMedia({ storage, repo }) qui pluge un repository ORM sur un driver
@mostajs/storage pour livrer un
MediaService consommable par les route handlers HTTP (Next.js App
Router, Express, Fastify, Hono, …).
🧩 Ce module est strictement server-side. Le client utilise
@mostajs/media(composants React, hooksuseMultiTakeSession, etc.) et appelle ce serveur via fetch.
Pourquoi un module séparé ?
@mostajs/media est ESM browser-first (React, MediaRecorder, IndexedDB).
Y empiler la persistance serveur (ORM, mailer, file I/O) :
- alourdit le bundle client d'environ 800 kB de dépendances inutiles ;
- complique le tree-shaking pour les apps Next.js / Vite ;
- mélange deux cycles de vie (browser API vs Node API) dans un même release.
@mostajs/media-server reste indépendant : pas de dep React, pas de
MediaRecorder, pas de @mostajs/orm en hard-dep (peer dep seulement).
Architecture en 1 schéma
┌─────────────────────────┐ POST /api/media/recordings ┌────────────────────────┐
│ Browser │ ───────────────────────────────▶ │ Route handler │
│ @mostajs/media │ │ (Next.js App Router, │
│ MultiTakeRecorder │ │ Express, Fastify…) │
│ videoStorage='server' │ ◀────────── 200 { row, signedUrl}│ │
└─────────────────────────┘ │ • RBAC accountId │
│ • parse multipart │
│ • mediaService.…(…) │
└────────────┬───────────┘
│
┌──────────────┼──────────────┐
▼ ▼
┌───────────────────┐ ┌──────────────────┐
│ MediaRepository │ │ @mostajs/storage │
│ (ORM, Prisma, …) │ │ FileStore │
│ → row metadata │ │ → blob bytes │
└───────────────────┘ └──────────────────┘Installation
npm install @mostajs/media-server @mostajs/storagePeer deps requises : @mostajs/storage ≥ 0.1.2, @mostajs/media ≥ 2.0.4
(pour le contract recordingMode).
How to use — 5 étapes
1. Préparer le storage (driver FS local pour la démo)
// lib/storage.ts
import { FilesystemDriver, createFileStore } from '@mostajs/storage'
const driver = new FilesystemDriver({
rootDir: '/home/hmd/storage/myapp',
signedUrlSecret: process.env.STORAGE_SIGNING_SECRET!,
signedUrlBaseUrl: 'https://myapp.example.com',
})
export const storage = createFileStore({ driver })2. Implémenter le MediaRepository (au-dessus de votre ORM)
Le repo est la seule surface ORM exposée — @mostajs/media-server ne
voit ni Prisma, ni @mostajs/orm, ni Drizzle. Vous le câblez à votre data layer.
// lib/media-repo.ts
import type { MediaRepository, MediaRow, MediaListFilter } from '@mostajs/media-server'
import { db } from './db' // votre client ORM
export const mediaRepo: MediaRepository = {
async insert(data) {
const id = data.id ?? crypto.randomUUID()
const row: MediaRow = {
...data,
id,
createdAt: new Date(),
}
await db.media.create({ data: row })
return row
},
async findById(id) {
return db.media.findUnique({ where: { id } })
},
async update(id, patch) {
return db.media.update({ where: { id }, data: { ...patch, updatedAt: new Date() } })
},
async delete(id) {
await db.media.delete({ where: { id } })
},
async list(accountId, filter) {
const status = filter?.status
? (Array.isArray(filter.status) ? filter.status : [filter.status])
: ['ready']
const rows = await db.media.findMany({
where: { accountId, status: { in: status } },
orderBy: { createdAt: 'desc' },
take: Math.min(filter?.limit ?? 50, 200),
})
return { items: rows }
},
}💡 Tip RBAC : ne JAMAIS filtrer par
userId. Toujours paraccountId, conformément à la mémoirefeedback_account_id_vs_user_iddu workspace.
3. Instancier le service
// lib/media-service.ts
import { createMedia } from '@mostajs/media-server'
import { storage } from './storage'
import { mediaRepo } from './media-repo'
export const mediaService = createMedia({
storage,
repo: mediaRepo,
defaultBucket: 'media-recordings',
signedUrlTtlSec: 60 * 60, // 1h
})4. Câbler les routes HTTP
Next.js App Router — POST /api/media/recordings
// app/api/media/recordings/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { mediaService } from '@/lib/media-service'
import { getAccountIdFromSession } from '@/lib/session' // votre helper auth
export const runtime = 'nodejs'
export async function POST(req: NextRequest) {
const accountId = await getAccountIdFromSession(req)
if (!accountId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
const form = await req.formData()
const blob = form.get('blob') as Blob
const mimeType = (form.get('mimeType') as string) || blob.type
const durationMs = Number(form.get('durationMs') || 0)
const sessionId = form.get('sessionId') as string | null
const takeIndex = form.get('takeIndex') ? Number(form.get('takeIndex')) : undefined
const buf = new Uint8Array(await blob.arrayBuffer())
const { row, signedUrl, signedUrlExpiresAt } = await mediaService.createRecording({
accountId,
body: buf,
mimeType,
durationMs,
sessionId: sessionId ?? undefined,
takeIndex,
})
return NextResponse.json({ row, signedUrl, signedUrlExpiresAt }, { status: 201 })
}Next.js App Router — GET /api/media/[id]
// app/api/media/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { mediaService } from '@/lib/media-service'
import { getAccountIdFromSession } from '@/lib/session'
export const runtime = 'nodejs'
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const accountId = await getAccountIdFromSession(req)
if (!accountId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
const { id } = await ctx.params
const result = await mediaService.getMedia(id)
if (!result) return NextResponse.json({ error: 'not_found' }, { status: 404 })
// RBAC : protection tenant — la row doit appartenir à l'account de la session
if (result.row.accountId !== accountId) {
return NextResponse.json({ error: 'forbidden' }, { status: 403 })
}
return NextResponse.json(result)
}
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const accountId = await getAccountIdFromSession(req)
if (!accountId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
const { id } = await ctx.params
const existing = await mediaService.getMedia(id)
if (existing && existing.row.accountId !== accountId) {
return NextResponse.json({ error: 'forbidden' }, { status: 403 })
}
await mediaService.deleteMedia(id)
return NextResponse.json({ ok: true })
}5. Câbler @mostajs/media côté browser
// app/page.tsx (camera-studio ou consumer custom)
'use client'
import { MultiTakeRecorder } from '@mostajs/media'
export default function Page() {
return (
<MultiTakeRecorder
initialVideoStorage="server"
initialVideoServerUrl="/api/media/recordings"
onSessionComplete={async (takes, sessionId, meta) => {
// Les takes sont déjà POSTés en chunks pendant l'enregistrement
// (storage='server'). Ici on peut lister/recharger via getMedia.
for (const take of takes) {
// take.videoResult.serverUrl pointera vers la row Media créée
console.log('Take saved:', take.id, take.videoResult.storage)
}
}}
/>
)
}API détaillée
createMedia(opts: CreateMediaOptions): MediaService
| Option | Type | Défaut | Notes |
|---------------------|-----------------|---------------------|-------|
| storage | StorageLike | — | FileStore @mostajs/storage |
| repo | MediaRepository | — | Votre repo ORM |
| defaultBucket | string | 'media-recordings' | Bucket cible des uploads |
| signedUrlTtlSec | number | 3600 (1h) | TTL signed URLs |
| generateId | () => string | base36 timestamp | Surcharger pour UUID/ULID |
MediaService
| Méthode | Signature | Effet |
|---------|-----------|-------|
| createRecording | (input) => Promise<{ row, signedUrl, signedUrlExpiresAt }> | insert row(uploading) → put storage → update row(ready) → sign URL. Si put fail → row.status=failed |
| getMedia | (id) => Promise<MediaWithSignedUrl \| null> | Récupère row + signedUrl frais. Renvoie null si status ≠ ready |
| reissueSignedUrl | (id, ttlSec?) => Promise<MediaWithSignedUrl \| null> | Re-sign sans toucher la row |
| deleteMedia | (id) => Promise<void> | Soft-delete row (status='deleted') puis purge storage |
| listForAccount | (accountId, filter?) => Promise<{ items, nextCursor? }> | Pagination multi-tenant |
MediaRow (row métadonnée)
{
id, accountId, ownerEmail?,
bucket, key,
mimeType, sizeBytes, durationMs, kind,
status: 'uploading' | 'ready' | 'deleted' | 'failed',
sessionId?, takeIndex?,
checksum?, metadata?,
createdAt, updatedAt?,
}Lifecycle row Media
[POST] ──▶ insert row(uploading) ──▶ storage.put() ──┬─▶ update row(ready) ──▶ signedUrl ──▶ 201
│
└─▶ (failure) update row(failed) ──▶ throw
[DELETE] ──▶ update row(deleted) ──▶ storage.delete() (best-effort, ne re-throw pas si fail)Tests & smoke
Pas de tests dans v0.1.0 — la livraison du 2e cas concret (iquesta
course-builder, après camera-studio) déclenchera la suite Vitest minimale
(mock storage + mock repo). Référence mémoire workspace :
project_deployment_validation_modules.
Smoke local rapide :
// test-smoke.ts (ad-hoc)
import { createMedia } from '@mostajs/media-server'
const memRepo = createInMemoryRepo() // implémentation locale du contract
const memStorage = createInMemoryStorage()
const svc = createMedia({ storage: memStorage, repo: memRepo })
const { row, signedUrl } = await svc.createRecording({
accountId: 'acc-1',
body: new Uint8Array([1, 2, 3]),
mimeType: 'video/webm',
durationMs: 5000,
})
console.assert(row.status === 'ready')
console.assert(signedUrl.startsWith('http'))Roadmap
| Version | Périmètre | ETA |
|---------|-----------|-----|
| 0.1.0 | Scaffold + factory + contract | ✅ Livré 19/05/2026 |
| 0.2.0 | Routes Next.js App Router prêtes (/handlers/next.ts) + smoke camera-studio | T+3 j |
| 0.3.0 | Tests Vitest (mock storage + repo) | T+7 j |
| 0.4.0 | Webhook events lifecycle (uploaded, ready, deleted) via @mostajs/notifications adapter | T+10 j |
| 0.5.0 | Quota par tenant + GC blobs orphelins | T+14 j |
| 1.0.0 | Stable API, smoke iquesta + camera-studio + 1 consumer externe | T+21 j |
Liens
- Design canonique :
mostajs/mosta-media/docs/MULTI-SOURCE-AND-RESUME-DESIGN.md§3 P1 - Plan iquesta :
SolutionCh/iquesta/docs/MEDIA-INTEGRATION-COURSE-BUILDER.md - Sibling client :
@mostajs/media - Storage driver :
@mostajs/storage
