Как написать модуль
Манифест, серверная часть, настройки, права и строки
Раскладка
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 обязаны попасть в бандл, иначе модуль не поднимется.
Сборка
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 импортируют относительным путём:
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> |
| Компоненты Vue | componentPrefix из манифеста |
Серверная часть
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>:
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 из репозитория модулей.
Дальше — встраивание в готовые экраны: меню, вкладки и слоты.