npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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, hooks useMultiTakeSession, 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/storage

Peer 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 par accountId, conformément à la mémoire feedback_account_id_vs_user_id du 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