Architecture

The big picture

FiveM needs three separate bundles:

  • resource/server/server.js — runs in the server Node.js runtime
  • resource/client/client.js — runs on every connected client
  • resource/ui/ — the NUI (React, shipped to Chromium)

Merinaa turns a pair of manifests into those bundles:

modules/merinaa.config.ts          app manifest — lists modules
modules/<name>/module.config.ts    module manifest — metadata, page/overlay declarations
                │
                │ pre-build generator (scripts/generate-module-registry.js)
                ▼
modules/.merinaa/server.ts         esbuild entry → resource/server/server.js
modules/.merinaa/client.ts         esbuild entry → resource/client/client.js
modules/.merinaa/ui.ts             vite entry     → resource/ui/

Each generated file references only its bundler's surface. The server bundle never sees TSX. The UI bundle never sees Deepkit.

App manifest

// modules/merinaa.config.ts
import { defineApp } from '@merinaa/core';
import { getDatabaseConfig } from './config';

import bank from './bank/module.config';
import character from './character/module.config';

export default defineApp({
    name: 'my-server',
    database: getDatabaseConfig(),
    modules: [bank, character],

    framework: {
        rateLimit: {
            windowMs: 1000,
            max: 20,
        },
    },
});

Module manifest

// modules/bank/module.config.ts
import { defineModule } from '@merinaa/core';

export default defineModule({
    name: 'bank',
    hasServer: true,        // default-exports @Module class from server/index.ts
    hasClient: true,        // side-effect imports client/index.ts

    ui: {
        pages: [
            {
                id: 'bank',
                event: 'bank:open',
                closeEvent: 'bank:close',
                component: 'pages/BankPage',
                focus: true,
                disableMovement: true,
                disableCamera: true,
                closeOnEscape: true,
            },
        ],
        overlays: [
            {
                id: 'bank:hud',
                component: 'overlays/BankHud',
                alwaysMounted: true,
            },
        ],
    },
});

hasServer variants

Value Meaning
true (default when server/index.ts exists) Default-imports the @Module-decorated class and hands it to Deepkit DI
'side-effect' Bare import '@modules/<name>/server' for modules that have no class (top-level RegisterCommand etc.)
false Skip on server entirely

Generator validation

On every build the generator checks:

  • Unique module names
  • Unique page ids (id either explicit or derived from event)
  • Unique page events
  • Unique overlay ids
  • Every component path resolves to a real .tsx / .ts / index.tsx file
  • dependsOn references resolve and have no cycles

A clear error with the module name is thrown if any check fails.

Generated files

Produced into modules/.merinaa/ (gitignored):

  • server.tsimport 'reflect-metadata'; bootstrapServer({ modules: [...], app, ... });
  • client.ts — side-effect imports + bootstrapClient({ pageOptions })
  • ui.tscreateUI({ modules: { bank: { pages: [...], overlays: [...] } } }).mount('#root');
  • page-ids.ts — typed PageId union for compile-time autocomplete on showPage()

Build pipeline

scripts/build.js:

  1. Run the generator
  2. Clean modules/dist/
  3. tspc -p modules/tsconfig.json (server compile with Deepkit reflection)
  4. tspc -p modules/tsconfig.client.json (client compile)
  5. esbuild bundles both
  6. Copy framework.config.yaml + emit version.json

Watch mode (pnpm run dev) also watches merinaa.config.ts + every */module.config.ts via chokidar and re-runs the generator on change.