UnicoreCMS

Как написать модуль

Манифест, серверная часть, настройки, права и строки

Раскладка

modules/<id>/
  module.json           манифест
  server/index.js       предсобранный CJS
  server-src/           исходники серверной части, в поставке не нужны
  shared/               общее для сервера и фронтов
  client/               слой Nuxt для сайта
  admin/                слой Nuxt для панели
  locales/{ru,en}.json  строки модуля

Серверная часть поставляется собранной: ядро грузит её через require, TypeScript в рантайме никто не компилирует. Собирайте tsup с @swc/core — метаданные декораторов Nest и TypeORM обязаны попасть в бандл, иначе модуль не поднимется.

Сборка

tsup.config.ts
import { defineConfig } from 'tsup'
import { MODULE_EXTERNALS } from 'unicore-api'

export default defineConfig({
  entry: ['server-src/index.ts'],
  outDir: 'server',
  format: ['cjs'],
  clean: true,
  sourcemap: true,
  splitting: false,
  target: 'es2021',
  external: MODULE_EXTERNALS,
})

MODULE_EXTERNALS — список пакетов, которые модуль обязан брать у ядра, а не тащить свои копии. Помимо очевидных @nestjs/* и typeorm туда входят class-validator и class-transformer, и как раз про них забывают.

package.json модуля нужен только здесь, на сборке: в установленном расширении его никто не читает, pnpm install на нём не запускается, а зависимости приезжают из корневого node_modules CMS. Поэтому не объявляйте в нём то, что и так есть у ядра, и не рассчитывайте, что владелец что-то доустановит. Нужен пакет, которого у ядра нет, — вшивайте его в бандл или везите своим node_modules внутри архива.

Своя копия class-validator тихо ломает DTO

Декораторы @IsString() и остальные пишут метаданные в хранилище своей копии пакета. Глобальный ValidationPipe ядра смотрит в своё — полей он там не находит и, поскольку включён whitelist, молча выбрасывает их из тела запроса. Контроллер получает объект без половины значений, ошибки нет, в базу уходят пустые колонки. Симптом узнаваемый: Field 'x' doesn't have a default value при том, что поле точно отправлялось.

Общий код модуля

Константы, типы и проверки, нужные и серверу, и фронтам, кладите в shared/ рядом с server-src. tsup вшивает их в бандл, а слои Nuxt импортируют относительным путём:

client/pages/mod/screens/index.vue
import { SCREEN_TYPES } from '../../../../shared/constants'

Так правило существует в одном месте. В «Конструкторе форм» из shared/ живут список типов полей, условия показа и функция проверки ответа — сайт подсвечивает ошибку и сервер отклоняет заявку по одному и тому же коду.

Манифест

{
  "id": "screens",
  "name": { "ru": "Скриншоты", "en": "Screenshots" },
  "description": { "ru": "Галерея скриншотов с серверов", "en": "Server screenshots" },
  "version": "1.0.0",
  "unicoreApi": "^1.0.0",
  "author": "Nick",
  "license": "MIT",
  "server": "./server/index.js",
  "locales": "./locales",
  "componentPrefix": "ModScreens",
  "permissions": [
    { "key": "mod.screens.read" },
    { "key": "mod.screens.write", "danger": true }
  ],
  "config": [
    {
      "key": "limit",
      "type": "number",
      "default": 20,
      "min": 1,
      "max": 100,
      "label": "mod.screens.config_limit",
      "hint": "mod.screens.config_limit_hint"
    }
  ],
  "client": "./client",
  "admin": "./admin"
}

Prop

Type

Права модуля

Право задаётся объектом; строкой тоже можно, если хватает одного ключа.

Prop

Type

Название права и подсказку модуль кладёт в свои строки: ключи perm.mod.<id>.<право>, perm.mod.<id>.<право>.hint и perm.group.mod.<id> для названия раздела. Без них в редакторе ролей будет виден сырой ключ.

Пространства имён

id задаёт префиксы, они проверяются при загрузке. Нарушение означает, что модуль не загрузится, а причина появится в панели.

ЧтоПрефикс
Таблицыmod_<id>_
Настройкиmod_<id>_, публичные public_mod_<id>_
Права и ключи строкmod.<id>., названия прав — perm.mod.<id>.
HTTP-маршруты/mod/<id>
Страницы фронтов/mod/<id>
Компоненты VuecomponentPrefix из манифеста

Серверная часть

import { defineModule } from 'unicore-api/server'
import { ScreensModule } from './screens.module'
import { Screenshot } from './entities/screenshot.entity'
import ru from '../locales/ru.json'
import en from '../locales/en.json'

export default defineModule({
  id: 'screens',
  entities: [Screenshot],
  nestModules: [ScreensModule],
  permissions: [{ key: 'mod.screens.read' }, { key: 'mod.screens.write', danger: true }],
  locales: { ru, en },
  setup(context) {
    context.logger.log('Модуль скриншотов подключён')

    context.events.on(
      'user.banned',
      async ({ uuid }) => {
        await context.core().db.getRepository(Screenshot).delete({ uuid })
      },
      context.id,
    )
  },
})

Сущности из entities попадают в общий DataSource, поэтому таблицы создаются сами при старте сервера. Модули Nest из nestModules подключаются к приложению как свои: контроллеры, провайдеры, задачи по расписанию работают без оговорок.

Обработчики событий не блокируют ядро, их ошибки логируются с префиксом [module:<id>]. Третий аргумент on — идентификатор модуля, по нему подписки снимаются при выключении.

Маршруты

import { Body, Controller, Get, Post } from '@nestjs/common'
import { CurrentUser, Permissions, Public } from 'unicore-api/server'

@Controller('mod/screens')
export class ScreensController {
  @Public()
  @Get()
  list() {}

  @Permissions(['mod.screens.write'])
  @Post()
  create(@CurrentUser() user: { uuid?: string }, @Body() body: unknown) {}
}

По умолчанию закрыто всё

Глобальные гварды ядра требуют входа на каждом маршруте, включая маршруты модулей. Публичной ручке нужен @Public(). А глобальный ValidationPipe({ whitelist: true }) вырезает из тела запроса поля, не описанные через class-validator, — описывайте DTO целиком, иначе значения молча не дойдут.

Настройки

Поля из config в манифесте превращаются в форму: на странице «Модули» у модуля со схемой появляется кнопка настроек. label и hint — ключи строк, а не готовый текст. Значение с public: true уезжает в публичный конфиг и доступно на сайте без авторизации.

Читать и писать настройки из кода — через фасад config:

const limit = await context.core().config.getNumber('mod_screens_limit', 20)

Строки

Ядро читает строки из папки, указанной в locales манифеста, и при старте досеивает их в общий словарь. Поправить перевод и перезапустить сервер достаточно — пересобирать модуль не нужно. Ключи начинаются с mod.<id>., редактируются в разделе «Локализация» наравне с остальными.

{
  "mod.screens.title": "Скриншоты",
  "mod.screens.config_limit": "Сколько показывать",
  "mod.screens.config_limit_hint": "От 1 до 100 снимков на странице."
}

Свои страницы

Страницы модуля живут в слое Nuxt и лежат по пути /mod/<id>:

client/nuxt.config.ts
export default defineNuxtConfig({
  components: [{ path: './components', prefix: 'ModScreens', pathPrefix: false }],
  routeRules: { '/mod/screens/my': { ssr: false } },
})

Страница за входом должна быть SPA

Страницу с middleware: ['auth'] объявляйте в routeRules как ssr: false. При серверном рендеринге токена ещё нет, и гостя отправит на страницу входа вместо содержимого.

Публикация

Чтобы модуль появился в каталоге панели, выложите его релизом на GitHub. Каталог читает релизы репозитория и ждёт от каждого одно: архив с именем <id>-<версия>.zip, где id — идентификатор из манифеста, а версия совпадает с version в нём. Черновики и предрелизы пропускаются, из нескольких релизов одного модуля берётся старшая версия.

Внутри архива — папка модуля без исходников сборки: module.json, server/, client/, admin/, locales/. Название и описание для каталога можно положить в текст релиза блоком JSON — тогда они покажутся до скачивания:

```json
{"name":{"ru":"Скриншоты","en":"Screenshots"},"description":{"ru":"Галерея скриншотов игроков"},"unicoreApi":"^1.4.0"}
```

Без блока каталог возьмёт название релиза и первый абзац его описания, а совместимость проверит уже при установке.

В репозиториях UnicoreCMS всё это делает Actions: тег вида screens-v1.2.0 собирает архив папки screens, сверяет версию с манифестом, пишет описание и публикует релиз. Тот же workflow подойдёт и своему репозиторию — скопируйте .github/workflows/release.yml из репозитория модулей.

Дальше — встраивание в готовые экраны: меню, вкладки и слоты.

Содержание