@brand-map/extension-sdk
v0.0.10-alpha.34
Published
`@brand-map/extension-sdk` - публичный пакет для разработки расширений Brand Map. Он дает авторам расширений компактный набор вспомогательных функций для объявления обработчиков, сгенерированный TypeScript-контракт операций, которые может вызывать расшире
Readme
@brand-map/extension-sdk
@brand-map/extension-sdk - публичный пакет для разработки расширений Brand Map. Он дает авторам расширений компактный набор вспомогательных функций для объявления обработчиков, сгенерированный TypeScript-контракт операций, которые может вызывать расширение, и Valibot-схемы публичного манифеста и DTO wire-форматов.
SDK намеренно узкий. Он не раскрывает внутренности хоста, строки хранилища установок, сообщения runner или валидацию sandbox-протокола. Эти контракты живут в runtime- и platform-пакетах. Авторы расширений должны воспринимать этот пакет как стабильную публичную поверхность.
Точки Входа Пакета
Опубликованный пакет открывает три точки входа:
import {
extension,
callback,
eventHandler,
scheduledJob,
lifecycleHook,
temporalWorkflow,
} from "@brand-map/extension-sdk";
import type { ExtensionRuntimeContext, ExtensionManifest } from "@brand-map/extension-sdk/types";
import {
ExtensionManifestSchema,
parseExtensionManifest,
} from "@brand-map/extension-sdk/validation";@brand-map/extension-sdkсодержит вспомогательные функции, используемые в исходном коде расширений.@brand-map/extension-sdk/typesсодержит сгенерированные публичные TypeScript-типы для манифестов, runtime context, DTO операций и контрактов сервисов операций.@brand-map/extension-sdk/validationсодержит публичные Valibot-схемы для манифестов расширений и DTO операций. Их использует сборщик, а также инструменты, которым нужно валидировать публичные контракты пакетов расширений.
Минимальное Расширение
Расширение экспортирует либо объект BrandMapExtension, либо фабричную функцию, которая возвращает такой объект. Обычный способ создать объект - extension(...).
import { callback, extension } from "@brand-map/extension-sdk";
export default extension({
name: "catalog-tools",
version: "0.1.0",
permissions: ["operation:product.list"],
callbacks: [
callback("list-products", async ({ brandMap }) => {
const products = await brandMap.product.list({ limit: 20 });
return { count: products.data.length };
}),
],
});Хелпер extension(...) одновременно строит:
- массивы runtime-обработчиков:
callbacks,eventHandlers,jobs,hooksиworkflows; - сериализуемый объект
manifest, полученный из обработчиков и metadata-полей входных данных.
Сборщик импортирует собранный бандл, читает extension.manifest, валидирует его и записывает dist/extension.manifest.json.
Структура Файлов
Типичный пакет расширения:
my-extension/
package.json
tsconfig.json
src/
extension.ts
exports.ts
docs/
README.md
SUMMARY.md
images/
setup.png
dist/
extension.js
extension.manifest.json
brand-map-my-extension-0.1.0.tgzsrc/exports.ts - точка входа бандла, которую использует brand-map-extension build.
export { catalogToolsExtension as default } from "./extension.js";Пакеты расширений должны быть ESM-пакетами:
{
"type": "module"
}Сборщик требует, чтобы dist/extension.js открывал ESM exports.
Метаданные
Входные данные extension(...) включают публичные метаданные пакета, объявления обработчиков, поля конфигурации и разрешения. Markdown-документация лежит в корне пакета docs/, а не в манифесте.
import { callback, extension } from "@brand-map/extension-sdk";
export default extension({
name: "stock-sync",
displayName: "Синхронизация остатков",
description: "Синхронизирует остатки из внешней системы.",
publisher: "Brand Map",
developerUrl: "https://brandmap.ru",
version: "0.1.0",
logo: {
src: "https://example.com/logo.png",
alt: "Синхронизация остатков",
},
config: [
{
key: "apiBaseUrl",
label: "Базовый URL API",
required: true,
type: "url",
},
{
key: "apiToken",
label: "API-токен",
required: true,
secret: true,
type: "string",
},
],
permissions: [
"operation:inventory.syncStockLevel",
"extension-storage:read",
"extension-storage:write",
],
callbacks: [
callback("health", async () => {
return { ok: true };
}),
],
});Документация обнаруживается из docs/ при упаковке или публикации:
docs/
README.md
SUMMARY.md
setup.md
images/
setup.pngИспользуйте обычные относительные Markdown-ссылки между страницами документации и соседними файлами ресурсов:
[Настройка](./setup.md)
Поддерживаемые типы конфигурационных полей:
booleanmulti-selectnumberselectstringurl
Для секретов используйте secret: true. Платформа хранит секретную конфигурацию отдельно от обычной конфигурации установки и подставляет ее в runtime-снимок там, где это нужно.
Разрешения
Разрешения расширений явные и управляются манифестом. Поддерживаемые формы:
type ExtensionPermission =
| "extension-storage:read"
| "extension-storage:write"
| `network:${string}`
| ExtensionOperationPermission;Разрешения операций используют сгенерированную форму:
operation:<service>.<operation>Пример:
permissions: ["operation:product.list", "operation:product.create"];Runtime проверяет объявленные разрешения перед выполнением операций хоста. Сборщик также сканирует прямые вызовы brandMap.<service>.<operation>(...) в бандле и отклоняет пакет, если нет соответствующего разрешения.
Проверить текущий каталог операций можно через сборщик:
brand-map-extension operationsСгенерированный справочник также хранится в ../OPERATIONS.md.
Контекст Runtime
Каждый обработчик получает ExtensionRuntimeContext и специфичное для обработчика значение invocation.
type ExtensionRuntimeContext = {
brandMap: ExtensionBrandMapOperations;
log: ExtensionLogger;
manifest: ExtensionManifest;
storage: ExtensionStorage;
};brandMap
brandMap - типизированный RPC-прокси для публичных операций хоста. Он повторяет сгенерированные контракты сервисов:
const products = await brandMap.product.list({ limit: 20 });
await brandMap.inventory.syncStockLevel({
inventoryItemId: "inv_item_1",
stockLocationId: "stock_location_1",
quantity: 12,
});Только операции из @brand-map/extension-sdk/types входят в публичный контракт расширений. Внутренние сервисы, destructive-операции, функции с зависимостями от колбэков, streams и приватные типы строк БД намеренно исключены.
log
Используйте context.log для структурированных runtime-логов:
context.log.info("Синхронизация началась", { cursor });
context.log.warn("Удаленный товар пропущен", { sku, reason: "не указан склад" });console.* перенаправляется runner, но context.log предпочтительнее, потому что сохраняет уровень лога и структурированные данные.
manifest
context.manifest - валидированный манифест, который хост загрузил для этой установки. Используйте его для проверок метаданных только на чтение, а не как изменяемое состояние.
storage
context.storage изолирован по установке. Одно и то же расширение, установленное для двух магазинов, получает разные хранилища.
type ExtensionStorage = {
cache: ExtensionStorageCache;
dataRoot: string;
db: ExtensionStorageDb;
files: ExtensionStorageFiles;
path: (...segments: string[]) => string;
};Методы хранилища требуют явных разрешений:
extension-storage:readдля чтения.extension-storage:writeдля записи и удаления.
Колбэк-Обработчики
Колбэки - HTTP-style действия расширения. Они вызываются через маршруты действий расширений и получают метаданные запроса, контекст авторизации, заголовки, query-параметры, полезную нагрузку и сырое тело.
import { callback, extension } from "@brand-map/extension-sdk";
export default extension({
name: "webhook-tools",
version: "0.1.0",
permissions: ["extension-storage:read", "extension-storage:write"],
callbacks: [
callback("receive", { action: "external-webhook" }, async ({ invocation, storage, log }) => {
log.info("Вебхук получен", {
path: invocation.request.path,
source: invocation.request.source,
});
await storage.db.set({
key: `webhooks.${invocation.invocationId}`,
value: invocation.payload ?? null,
});
return {
status: 202,
body: { accepted: true },
};
}),
],
});callback(name, handler) использует одно значение для name и публичного action. callback(name, { action }, handler) позволяет сохранить стабильное внутреннее имя обработчика и открыть другое имя действия наружу.
Обработчики Событий
Обработчики событий подписываются на события платформы по имени события.
import { eventHandler, extension } from "@brand-map/extension-sdk";
export default extension({
name: "order-events",
version: "0.1.0",
eventHandlers: [
eventHandler("order-created", { event: "order.created" }, async ({ invocation, log }) => {
log.info("Событие заказа получено", {
event: invocation.event.name,
entityId: invocation.event.entityId,
});
}),
],
});Запланированные Задачи
Запланированные задачи объявляют расписание в манифесте. Runtime регистрирует расписания через планировщик хоста, когда поддержка расписаний включена.
import { extension, scheduledJob } from "@brand-map/extension-sdk";
export default extension({
name: "nightly-sync",
version: "0.1.0",
jobs: [
scheduledJob(
"sync",
{
schedule: "0 2 * * *",
timezone: "Europe/Moscow",
overlap: "SKIP",
},
async ({ invocation, log }) => {
log.info("Запланированная синхронизация началась", {
scheduledAt: invocation.scheduledAt,
trigger: invocation.trigger,
});
},
),
],
});Политики пересечения:
ALLOW_ALLBUFFER_ALLBUFFER_ONECANCEL_OTHERSKIPTERMINATE_OTHER
Хуки Жизненного Цикла
Хуки жизненного цикла реагируют на события жизненного цикла расширения, которые отправляет платформа, например установку и изменение конфигурации.
import { extension, lifecycleHook } from "@brand-map/extension-sdk";
export default extension({
name: "lifecycle-audit",
version: "0.1.0",
hooks: [
lifecycleHook("installed", { event: "extension.installed" }, async ({ invocation, log }) => {
log.info("Расширение установлено", {
payload: invocation.event.payload,
});
}),
],
});Temporal Workflow
Temporal-процессы объявляют workflow-обработчики по имени. Они используют ту же обертку вызова и результата, что и другие обработчики, но предназначены для долгих workflow, оркестрируемых хостом.
import { extension, temporalWorkflow } from "@brand-map/extension-sdk";
export default extension({
name: "workflow-demo",
version: "0.1.0",
workflows: [
temporalWorkflow("rebuild-index", async ({ invocation, log }) => {
log.info("Workflow начался", { input: invocation.input });
return { done: true };
}),
],
});Хуки Запуска И Остановки
extension(...) также принимает start и stop. Эти функции не являются обработчиками манифеста. Runner вызывает их при старте и остановке процесса расширения.
import { extension } from "@brand-map/extension-sdk";
export default extension({
name: "with-lifecycle",
version: "0.1.0",
async start({ log }) {
log.info("Runner расширения запущен");
},
async stop({ log }) {
log.info("Runner расширения останавливается");
},
});Используйте start только для легкой подготовки. Тяжелая работа должна жить в колбэках, событиях, задачах, хуках или workflow, чтобы хост мог применять обычные таймауты вызовов и наблюдаемость.
Возвращаемые Значения
Обработчики могут возвращать:
undefined- JSON-совместимые значения
- строки
- явные HTTP-like response objects
return { ok: true };return "обычный текст";return {
status: 201,
headers: {
"content-type": "application/json",
},
body: {
created: true,
},
};undefined нормализуется runner в:
{
status: 204,
body: { kind: "empty" }
}Для явного body поддерживаются JSON-совместимые значения, строки и структурированные extension bodies:
return {
body: {
kind: "text",
text: "ok",
},
};return {
body: {
kind: "bytes",
base64: "aGVsbG8=",
mimeType: "text/plain",
},
};Постоянное Хранилище
Используйте storage.db для надежных JSON-записей:
await storage.db.set({
key: "sync.cursor",
value: {
page: 3,
remoteCursor: "abc",
},
metadata: {
source: "remote-api",
},
});
const cursor = await storage.db.get("sync.cursor");
const allSyncRecords = await storage.db.list({ prefix: "sync.", limit: 100 });
await storage.db.delete("sync.cursor");Ключи ограничены областью установки. Выбирайте стабильные ключи с пространством имен: sync.cursor, orders.lastProcessedAt или remote.product.<id>.
Временный Кеш
Используйте storage.cache для временных JSON-значений:
await storage.cache.set({
key: "remote-token",
value: {
token: "redacted",
},
ttl: 60 * 15,
});
const token = await storage.cache.get("remote-token");
await storage.cache.delete("remote-token");ttl задается в секундах. Значения кеша могут быть вытеснены провайдером хоста, поэтому не используйте кеш как надежное состояние.
Файлы
Используйте storage.files для blob-данных или больших полезных нагрузок, которые должен обрабатывать файловый сервис хоста:
const file = await storage.files.create({
filename: "report.json",
mimeType: "application/json",
content: JSON.stringify({ ok: true }),
});
const bytes = await storage.files.getAsBuffer(file.id);
const retrieved = await storage.files.retrieve(file.id);
await storage.files.delete(file.id);Файлы привязаны к текущей установке, чтобы очистка при удалении расширения могла удалить принадлежащие ей файловые ссылки.
Корневая Директория Данных
storage.dataRoot - директория данных установки, предоставленная хостом. storage.path(...segments) склеивает пути внутри этого корня.
const localPath = storage.path("tmp", "export.json");Для обычного состояния расширения предпочитайте storage.db, storage.cache и storage.files. Прямые записи в файловую систему подходят только для временных файлов, локальных инструментов или workflow, которым явно нужны пути.
Фабрика Расширений
Бандл расширения может экспортировать фабричную функцию. Runtime вызывает фабрику с параметрами установки из дескриптора хоста.
import { callback, extension } from "@brand-map/extension-sdk";
type Options = {
apiBaseUrl?: string;
};
export default function createExtension(options: Options) {
return extension({
name: "factory-extension",
version: "0.1.0",
callbacks: [
callback("config", () => {
return {
apiBaseUrl: options.apiBaseUrl ?? null,
};
}),
],
});
}Фабрики полезны для встроенных расширений, где хост подставляет рабочие параметры. Пользовательские поля установки все равно нужно объявлять через config.
Рабочий Процесс Сборщика
SDK обычно используется вместе с @brand-map/extension-builder.
bun add @brand-map/extension-sdk
bun add -d @brand-map/extension-builderСоздать пакет:
brand-map-extension scaffold my-extensionПосмотреть операции:
brand-map-extension operationsTypecheck:
brand-map-extension typecheckСобрать для локальной разработки:
brand-map-extension buildСобрать для публикации:
brand-map-extension build --publishУпаковать:
brand-map-extension packpack запускает publish build перед архивированием. pack --publish поддерживается как явная форма.
Архив содержит только:
package.json
dist/extension.js
dist/extension.manifest.jsonИсходники, тесты, sourcemap-файлы и локальные артефакты репозитория не включаются.
Локальная Сборка И Сборка Для Публикации
Локальные сборки оптимизированы для разработки:
src/exports.tsсобирается вdist/extension.js.- Бандл является современным ESM.
- Включена минификация пробелов Bun.
- Манифест пишется в
dist/extension.manifest.json. - Выходной файл остается достаточно читаемым для отладки.
Сборки для публикации оптимизированы для распространения:
- Включена полная минификация Bun.
javascript-obfuscatorприменяет умеренную обфускацию.- Sourcemap-файлы отключены.
- Валидация запускается после обфускации.
- Шаг
packвалидирует финальный артефакт перед записью.tgz.
Настройки обфускатора намеренно избегают агрессивных функций: выравнивания потока управления, вставки мертвого кода, глобального переименования, защиты от отладки и самозащиты кода. Они повышают runtime-риск и стоимость старта. Текущий режим публикации дает умеренное укрепление исходников, сохраняя стабильное поведение runtime расширения.
Валидация Бандла
Сборщик отклоняет бандлы, нарушающие публичные ограничения расширений:
- Пакет должен объявлять
"type": "module". dist/extension.jsдолжен быть современным ESM.- Неподдерживаемые разрешения отклоняются.
- Дублирующиеся колбэки, события, задачи, хуки или workflow отклоняются.
- Удаленные legacy-вызовы операций отклоняются.
- Прямые вызовы
brandMap.<service>.<operation>(...)должны иметь соответствующие разрешения в манифесте. - Приватные импорты Brand Map отклоняются.
- Утечки путей host-репозитория отклоняются.
- Запрещенные host imports, например
node:child_process,node:cluster,node:worker_threadsиbun:ffi, отклоняются.
В бандле расширения разрешены только эти корни пакетов @brand-map/*:
new Set([
"@brand-map/extension-sdk",
"@brand-map/types",
"@brand-map/ui",
"@brand-map/admin-extension-sdk",
]);Allowlist сопоставляется по корню пакета. Например, @brand-map/ui/components/button разрешен, потому что его корень пакета - @brand-map/ui; приватные runtime-пакеты Brand Map отклоняются.
Ответственность За Валидацию
Валидация намеренно разделена по границам контрактов:
extension-sdk/validationотвечает за публичные контракты пакетов расширений, контракт манифеста, wire-контракты DTO операций и схемы, нужные публичным инструментам сборщика.extension-runtimeотвечает за валидацию протокола вызова runner/host, проверку сообщений sandbox и runtime-only safety checks.platform/extensionsотвечает за валидацию storage/readback установок и persistence shapes manager-сервиса.
Такое разделение делает публичную валидацию SDK полезной, не раскрывая внутреннее хранилище хоста или детали sandbox-протокола.
Генерация Типов
Большинство публичных типов в @brand-map/extension-sdk/types генерируется из Valibot-схем и каталога операций. Не редактируйте сгенерированные файлы вручную.
Семейства сгенерированных типов:
- Общие JSON и query DTO.
- Manifest DTO.
- DTO товаров, цен, inventory, stock location, notifications и files.
- Контракты операций, сгруппированные по сервисам.
- Каталог операций и типы строк разрешений.
- Runtime context и типы вызовов обработчиков.
Проверить контракты пакетов расширений из корня workspace:
bun run check:contractsСобрать публичные пакеты расширений:
bun run build:publicПробный запуск публичной публикации:
bun run publish:public --dry-runЛучшие Практики
Объявляйте самые узкие разрешения. Если колбэк только читает товары, объявляйте operation:product.list, а не все разрешения product.
По возможности делайте обработчики идемпотентными. Обработчики событий и задачи могут повторяться хостом.
Используйте storage.db для курсоров и надежного состояния синхронизации. Используйте storage.cache для токенов и короткоживущих ответов удаленных API. Используйте storage.files для больших артефактов.
Держите работу при старте небольшой. Используйте start для легкой подготовки и проверок здоровья, а не для импортов, синхронизаций или workflow с тяжелой сетевой нагрузкой.
Возвращайте структурированные объекты ответа из публичных колбэков, которым нужны статус-коды или заголовки.
Используйте context.log вместо console.*.
Импортируйте только публичные пакеты. Не импортируйте platform, runtime, kernel, database или пакеты только для хоста.
Запускайте brand-map-extension pack перед отправкой. Он строже обычной сборки и валидирует финальный артефакт публикации.
Диагностика
Extension package must declare "type": "module"
Добавьте "type": "module" в package.json пакета расширения.
dist/extension.js must be modern ESM
Бандл не содержит ESM exports. Убедитесь, что расширение экспортируется из src/exports.ts, и соберите через brand-map-extension build.
calls Brand Map operation ... but does not declare permission
Добавьте нужное разрешение operation:<service>.<operation> в extension({ permissions: [...] }) или удалите вызов операции.
contains unallowed Brand Map import
Расширение импортировало приватный пакет Brand Map. Замените импорт на @brand-map/extension-sdk, @brand-map/extension-sdk/types, публичный пакет UI/admin SDK или обычную стороннюю зависимость.
declares duplicate callback handler
Имена обработчиков должны быть уникальны внутри каждого типа обработчиков. Переименуйте один из дублирующихся колбэков, обработчиков событий, задач, хуков или workflow.
extension manifest is missing
Запустите:
brand-map-extension buildСборка создаст dist/extension.manifest.json.
Полный Пример
import { callback, eventHandler, extension, scheduledJob } from "@brand-map/extension-sdk";
import { DateTime } from "luxon";
export default extension({
name: "catalog-sync",
displayName: "Синхронизация каталога",
description: "Синхронизирует данные каталога с удаленной системой.",
version: "0.1.0",
config: [
{
key: "apiBaseUrl",
label: "Базовый URL API",
required: true,
type: "url",
},
{
key: "apiToken",
label: "API-токен",
required: true,
secret: true,
type: "string",
},
],
permissions: [
"operation:product.list",
"extension-storage:read",
"extension-storage:write",
"network:https://api.example.com",
],
callbacks: [
callback("sync-now", async ({ brandMap, invocation, log, storage }) => {
log.info("Запрошена ручная синхронизация", {
invocationId: invocation.invocationId,
});
const products = await brandMap.product.list({ limit: 50 });
await storage.db.set({
key: "sync.lastManualRun",
value: {
at: DateTime.utc().toISO()!,
count: products.data.length,
},
});
return {
status: 200,
body: {
synced: products.data.length,
},
};
}),
],
eventHandlers: [
eventHandler("product-updated", { event: "product.updated" }, async ({ invocation, log }) => {
log.info("Товар изменен", {
entityId: invocation.event.entityId,
});
}),
],
jobs: [
scheduledJob(
"nightly-sync",
{ overlap: "SKIP", schedule: "0 3 * * *", timezone: "Europe/Moscow" },
async ({ log, storage }) => {
const lastRun = await storage.db.get("sync.lastManualRun");
log.info("Ночная синхронизация началась", {
lastRun: lastRun?.value,
});
},
),
],
});