@360crewing/marketplace-sdk
v0.3.0
Published
Public SDK contract for 360crewing marketplace extensions: defineExtension, hooks, slot types.
Readme
@360crewing/marketplace-sdk
Public SDK contract for 360crewing marketplace extensions. This is a
contract: after 0.1.0, breaking API changes ship only in a major version.
Install
npm i @360crewing/marketplace-sdkPeer dependencies (provided by the host via Module Federation shared — an
extension does NOT bundle its own copies): react, axios, react-i18next,
@360crewing/ui.
Quick start
import {defineExtension, useUser, useI18n, Card, Button, Slot} from "@360crewing/marketplace-sdk";
import en from "./locales/en.json";
// dashboard.widget — a card on the dashboard (bare component).
function KpiWidget() {
const user = useUser();
const {t} = useI18n();
return (
<Card title={t("title")}>
<p>{t("greeting", {name: user.full_name})}</p>
<Button title={t("refresh")} onClick={() => {}}/>
</Card>
);
}
// seafarer.menu — a menu item on the seafarer card + the page it opens.
function CrewAnalyticsPage(props: {seafarer: {uuid: string}}) {
return <div>Analytics for seafarer {props.seafarer.uuid}</div>;
}
export default defineExtension({
id: "com.acme.crew-analytics", // reverse-DNS, unique
locales: {en}, // >=1 locale; files in locales/*.json
slots: {
[Slot.Dashboard.Widget]: KpiWidget, // widget: a component
[Slot.Seafarer.Menu]: { // menu: {title,icon?,page}
title: "Crew Analytics",
page: CrewAnalyticsPage,
},
},
});Slot model
A slot is a named mount point in the host. The slot name = where the extension appears. Two archetypes:
| Archetype | Slots | What you register |
|---|---|---|
| widget | Slot.Dashboard.Widget | a bare React component (a card in the dashboard grid) |
| menu | Slot.Seafarer.Menu, Slot.Vessel.Menu, Slot.Contract.Menu, Slot.Nav.{Admin,Data,Finance,Fleet,Reports}.Menu | MenuRegistration { title, icon?, page } |
Slotis a nested namespace (one import):Slot.Seafarer.Menu,Slot.Nav.Data.Menu,Slot.Dashboard.Widget. A raw string key ("seafarer.menu") is also accepted and type-safe.- menu slot:
titleis the item label,pageis the page component the host renders on click (the route is derived from the extensionid). - One extension can occupy several slots; several extensions in one slot → several menu items.
- Context in props: widget →
{ widget:{id,settings}, sdk }; entity-menu →{ seafarer|vessel|contract:{uuid}, sdk }; nav-menu →{ sdk }.
defineExtension validates: reverse-DNS id, >=1 locale, >=1 slot, and that a
menu slot is { title, page }.
i18n
- Text lives in
locales/<lang>.json;defineExtension({ locales: { en, ru } }). - The host registers them as the i18next namespace
app.<id>on load. useI18n()isreact-i18nextbound to that namespace:const {t} = useI18n(); t("title"). Content re-renders when the user changes language (no reload).- No translation for the active language → falls back to the host default.
Storage
The platform gives an extension two primitives (no backend of your own):
useUserSettings() / sdk.userSettings — per-user settings (saved filters,
UI prefs). Small JSON, last-write-wins, scoped per user.
const us = useUserSettings<{ visaFilter?: object }>();
await us.set({ visaFilter }); // shallow-merge
const { visaFilter } = await us.get(); // whole documentuseDb() / sdk.db — the extension's own database: one isolated SQLite
file per (tenant, installation). Full SQL, the extension owns its schema.
query (SELECT), exec (INSERT/UPDATE/DELETE/DDL), tx.
const db = useDb();
// schema — once (on install / migration)
await db.exec(`CREATE TABLE IF NOT EXISTS visa(
id INTEGER PRIMARY KEY, seafarer_uuid TEXT, country TEXT,
status TEXT, applied_at TEXT, docs JSON)`);
// CREATE
await db.exec(
`INSERT INTO visa(seafarer_uuid,country,status,applied_at,docs) VALUES(?,?,?,?,?)`,
[uuid, "US", "pending", "2026-05-18", JSON.stringify(["passport","photo"])]);
// READ + filter, including an array column (JSON1)
const rows = await db.query(`
SELECT * FROM visa
WHERE status IN ('pending','submitted') AND country = ?
AND EXISTS (SELECT 1 FROM json_each(docs) WHERE value = 'passport')
ORDER BY applied_at DESC LIMIT 50`, ["US"]);
// UPDATE / DELETE / transaction
await db.exec(`UPDATE visa SET status=? WHERE id=?`, ["approved", 1]);
await db.tx(async (t) => {
await t.exec(`DELETE FROM visa WHERE status='rejected'`);
await t.exec(`INSERT INTO audit(ts) VALUES(?)`, [Date.now()]);
});Isolation: the server derives tenant/installation from the app-JWT, not the
request body; SQL is pinned to that file — no cross-extension data. Host guards:
SQLite authorizer denylist (no ATTACH/load_extension/file pragmas),
per-query timeout, result cap, file quota, write serialization. The SQLite
dialect is a stable public contract.
What's included
| Group | API |
|---|---|
| Entry point | defineExtension({ id, locales, slots, onActivate?, onDeactivate? }) |
| Hooks | usePlatform, useUser, useTenant, useNotify, useModal, useConfirm, useForm, useTheme, useI18n, useNavigate, useUsage, useDb, useUserSettings |
| Slots | Slot.* + MenuRegistration + context types (DashboardWidgetContext, SeafarerMenuContext, VesselMenuContext, ContractMenuContext, NavMenuContext) |
| UI | re-exports all @360crewing/ui primitives (Button, Card, TextField, …) |
Boundaries
- Hooks are the only way to use host services (Modal / Toast / Confirm / Form / platform data / i18n / navigation). Extensions have no direct access to host managers.
usePlatform()is a typed client for platform data (platform.seafarers/vessels/contracts→.list()/.search(params)/.get(uuid)). No hand-written URLs or JSON; the host injects an app-scoped JWT and the server checks scope. No raw axios.useNavigate()navigates the host app (host-absolute paths).- Hooks work only inside a component the host mounted into a slot. Calling them at module scope throws a clear error.
Building & shipping an extension
An extension is a Module Federation remote, built with the reference MF
tooling (Rsbuild/Rspack, @module-federation/rsbuild-plugin), that exposes
./extension = the default export of defineExtension(...). @360crewing/* and
react are declared shared (import:false) so the extension always uses the
host's instance (deduped across the MF boundary). A working reference lives in
this package's example/ directory (rsbuild.config.ts + locales/).
