A TypeScript client for interacting with Cockpit CMS, including GraphQL requests, content management, and schema stitching support.
npm install --save @unchainedshop/cockpit-apiThis package provides three entry points:
| Export | Description |
|---|---|
@unchainedshop/cockpit-api |
Full-featured async API client with caching and response transformation |
@unchainedshop/cockpit-api/schema |
GraphQL schema stitching utilities |
@unchainedshop/cockpit-api/fetch |
Lightweight client for edge/RSC environments |
import { CockpitAPI } from '@unchainedshop/cockpit-api';
// With explicit endpoint
const cockpit = await CockpitAPI({
endpoint: 'https://your-cockpit-instance.com/api/graphql',
});
// Or using environment variables
const cockpit = await CockpitAPI(); // Uses COCKPIT_GRAPHQL_ENDPOINTimport { createFetchClient } from '@unchainedshop/cockpit-api/fetch';
// Synchronous initialization - no await needed
const cockpit = createFetchClient({
endpoint: process.env.NEXT_PUBLIC_COCKPIT_ENDPOINT,
tenant: 'mytenant',
cache: 'force-cache', // Uses platform caching
});
const page = await cockpit.pageByRoute('/about', { locale: 'en' });import { makeCockpitGraphQLSchema } from '@unchainedshop/cockpit-api/schema';
import { stitchSchemas } from '@graphql-tools/stitch';
const cockpitSchema = await makeCockpitGraphQLSchema({
tenantHeader: 'x-cockpit-space',
filterMutations: true,
});
const gatewaySchema = stitchSchemas({
subschemas: [{ schema: cockpitSchema }],
});import { gql } from 'graphql-tag';
const query = gql`
query {
allPosts {
title
content
}
}
`;
const result = await cockpit.graphQL(query, {});// Get a single content item
const post = await cockpit.getContentItem({ model: 'posts', id: '123' });
// With locale and field selection
const localizedPost = await cockpit.getContentItem({
model: 'posts',
id: '123',
locale: 'en',
queryParams: { fields: { title: 1, content: 1 } }
});
// Get multiple content items - always returns { data, meta? }
const response = await cockpit.getContentItems('posts', {
limit: 10,
sort: { _created: -1 },
filter: { published: true }
});
// response: { data: Post[], meta?: { total: number } } | null
// Access items and metadata
const items = response?.data || [];
const total = response?.meta?.total;
// Get tree structure
const tree = await cockpit.getContentTree('categories', {
parent: 'root-id',
populate: 2
});
// Aggregation pipeline
const stats = await cockpit.getAggregateModel({
model: 'orders',
pipeline: [{ $group: { _id: '$status', count: { $sum: 1 } } }]
});
// Create content item
const newPost = await cockpit.postContentItem('posts', { title: 'New Post' });
// Delete content item
await cockpit.deleteContentItem('posts', '123');// List pages - always returns { data, meta? }
const response = await cockpit.pages({ locale: 'en', limit: 50 });
const allPages = response?.data || [];
const total = response?.meta?.total;
// Get page by ID
const page = await cockpit.pageById({ page: 'blog', id: '123', locale: 'en' });
// Get page by route
const aboutPage = await cockpit.pageByRoute('/about', { locale: 'en', populate: 2 });// Get all menus
const menus = await cockpit.pagesMenus({ locale: 'en' });
// Get specific menu
const mainMenu = await cockpit.pagesMenu('main-navigation', { locale: 'en' });const routes = await cockpit.pagesRoutes('en');
const sitemap = await cockpit.pagesSitemap();
const settings = await cockpit.pagesSetting('en');
const fullRoute = await cockpit.getFullRouteForSlug('my-slug');const results = await cockpit.search({
index: 'products',
q: 'search term',
limit: 10,
offset: 0
});const translations = await cockpit.localize('my-project', {
locale: 'en',
nested: true
});import { ImageSizeMode, MimeType } from '@unchainedshop/cockpit-api';
// Get asset metadata
const asset = await cockpit.assetById('asset-id');
// Get transformed image
const image = await cockpit.imageAssetById('asset-id', {
m: ImageSizeMode.BestFit,
w: 800,
h: 600,
q: 80,
mime: MimeType.WEBP
});// Health check
const health = await cockpit.healthCheck();
// Clear cache (async in v2.2.0+)
await cockpit.clearCache(); // Clear all
await cockpit.clearCache('pages'); // Clear by patternThe fetch client is designed for edge/RSC environments with minimal overhead:
import { createFetchClient } from '@unchainedshop/cockpit-api/fetch';
const cockpit = createFetchClient({
endpoint: process.env.NEXT_PUBLIC_COCKPIT_ENDPOINT,
tenant: 'mytenant',
cache: 'force-cache',
apiKey: 'your-api-key',
headers: { 'X-Custom-Header': 'value' }
});
// Available methods
const page = await cockpit.pageByRoute('/about', { locale: 'en' });
// List methods return { data, meta? }
const pagesResponse = await cockpit.pages({ locale: 'en' });
const pages = pagesResponse?.data || [];
const pageById = await cockpit.pageById('blog', '123', { locale: 'en' });
const itemsResponse = await cockpit.getContentItems('news', { locale: 'en', limit: 10 });
const items = itemsResponse?.data || [];
const item = await cockpit.getContentItem('news', '123', { locale: 'en' });
const custom = await cockpit.fetchRaw('/custom/endpoint', { param: 'value' });For building GraphQL gateways with Cockpit:
import { makeCockpitGraphQLSchema, createRemoteExecutor } from '@unchainedshop/cockpit-api/schema';
// Create schema for stitching
const schema = await makeCockpitGraphQLSchema({
tenantHeader: 'x-cockpit-space',
filterMutations: true,
transforms: [], // Additional GraphQL transforms
extractTenant: (ctx) => ctx.req?.headers['x-tenant'],
cockpitOptions: {
endpoint: 'https://cms.example.com/api/graphql',
apiKey: 'your-api-key',
useAdminAccess: true
}
});
// Or use the executor directly for custom implementations
const executor = createRemoteExecutor({
tenantHeader: 'x-cockpit-space',
cockpitOptions: { endpoint: '...' }
});const cockpit = await CockpitAPI({
endpoint: 'https://...', // Falls back to COCKPIT_GRAPHQL_ENDPOINT
tenant: 'mytenant', // Optional: for multi-tenant setups
apiKey: 'your-api-key', // Falls back to COCKPIT_SECRET env var
useAdminAccess: true, // Optional: inject api-Key header
defaultLanguage: 'de', // Language that maps to Cockpit's "default" locale (default: "de")
preloadRoutes: true, // Optional: preload route replacements
cache: {
max: 100, // Falls back to COCKPIT_CACHE_MAX (default: 100)
ttl: 100000, // Falls back to COCKPIT_CACHE_TTL (default: 100000)
store: customStore, // Optional: custom async cache store (Redis, Keyv, etc.)
},
// Or disable caching entirely
// cache: false,
});COCKPIT_GRAPHQL_ENDPOINT=https://your-cockpit-instance.com/api/graphql
COCKPIT_SECRET=your-api-key # Default API key
COCKPIT_SECRET_MYTENANT=tenant-api-key # Tenant-specific API key
COCKPIT_CACHE_MAX=100 # Max cache entries (default: 100)
COCKPIT_CACHE_TTL=100000 # Cache TTL in ms (default: 100000)// Tenant-specific client
const cockpit = await CockpitAPI({
endpoint: 'https://cms.example.com/api/graphql',
tenant: 'mytenant', // Requests use /:mytenant/api/... path
});
// Resolve tenant from URL
import { resolveTenantFromUrl, getTenantIds } from '@unchainedshop/cockpit-api';
const { tenant, slug } = resolveTenantFromUrl('https://mytenant.example.com/page');
const allTenants = getTenantIds(); // From COCKPIT_SECRET_* env varsv2.2.0+ supports pluggable async cache stores for Redis, Keyv, or custom implementations:
import { createClient } from 'redis';
import type { AsyncCacheStore } from '@unchainedshop/cockpit-api';
// Redis example
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
const redisStore: AsyncCacheStore = {
async get(key: string) {
const value = await redisClient.get(key);
return value ? JSON.parse(value) : undefined;
},
async set(key: string, value: unknown) {
await redisClient.set(key, JSON.stringify(value), { EX: 100 });
},
async clear(pattern?: string) {
if (pattern) {
const keys = await redisClient.keys(`${pattern}*`);
if (keys.length > 0) await redisClient.del(keys);
} else {
await redisClient.flushDb();
}
}
};
const cockpit = await CockpitAPI({
endpoint: 'https://cms.example.com/api/graphql',
cache: { store: redisStore }
});All list methods return a consistent response format regardless of parameters:
interface CockpitListResponse<T> {
data: T[];
meta?: CockpitListMeta; // Present when using pagination (skip parameter)
}getContentItems()- Always returnsCockpitListResponse<T> | nullpages()- Always returnsCockpitListResponse<T> | null- Fetch client methods - Always return
CockpitListResponse<T> | null
import type { CockpitListResponse } from '@unchainedshop/cockpit-api';
// Always get { data, meta? } format
const response = await cockpit.getContentItems('posts', { limit: 10, skip: 0 });
// Access items
const items = response?.data || [];
// Access metadata (available when using skip parameter)
const total = response?.meta?.total;Benefits:
- No need to check if response is array or object
- Predictable type signatures
- Easier to work with pagination
import type {
// Client
CockpitAPIClient,
CockpitAPIOptions,
CacheManager,
CacheOptions,
AsyncCacheStore,
// Query Options
ContentItemQueryOptions,
ContentListQueryOptions,
TreeQueryOptions,
PageQueryOptions,
SearchQueryOptions,
ImageAssetQueryParams,
// Response Types
CockpitPage,
CockpitAsset,
CockpitMenu,
CockpitRoute,
CockpitSearchResult,
CockpitContentItem,
CockpitListResponse, // New: for paginated content responses
CockpitListMeta, // New: metadata in paginated responses
// Schema Types
MakeCockpitSchemaOptions,
CockpitExecutorContext,
// Fetch Types
FetchClientOptions,
FetchCacheMode,
} from '@unchainedshop/cockpit-api';
import { ImageSizeMode, MimeType } from '@unchainedshop/cockpit-api';lokalize()renamed tolocalize()- Methods use options objects instead of positional parameters
- HTTP errors now throw instead of returning
null(404 still returnsnull) - Each client instance has its own cache (no shared state)
/schemasubpackage for GraphQL schema stitching/fetchsubpackage for lightweight edge/RSC environmentspreloadRoutesoption for preloading route replacementsdefaultLanguageoption to configure which language maps to Cockpit's "default" locale- Expanded tenant utilities:
resolveTenantFromUrl(),resolveTenantFromSubdomain()
Async Cache Operations:
- All cache operations are now async and return Promises
await cockpit.clearCache()is now required (was synchronous in v2.1.x)- Custom cache stores can be provided via
cache.storeoption - Cache can be explicitly disabled with
cache: false
Migration:
// Before (v2.1.x)
cockpit.clearCache();
cockpit.clearCache('ROUTE');
// After (v2.2.0)
await cockpit.clearCache();
await cockpit.clearCache('ROUTE');Consistent List Response Format:
All list methods now return CockpitListResponse<T> | null instead of varying between arrays and wrapped responses:
Changed Methods:
getContentItems()- Now always returns{ data: T[], meta?: {...} } | nullpages()- Now always returns{ data: T[], meta?: {...} } | null- Fetch client
getContentItems()andpages()- Now always return{ data: T[], meta?: {...} } | null
Migration:
// Before (v2.x)
const items = await cockpit.getContentItems('posts', { limit: 10 });
// items could be Post[] or null
const pages = await cockpit.pages({ limit: 10 });
// pages could be Page[] or null
// After (v3.0.0)
const itemsResponse = await cockpit.getContentItems('posts', { limit: 10 });
const items = itemsResponse?.data || [];
const total = itemsResponse?.meta?.total;
const pagesResponse = await cockpit.pages({ limit: 10 });
const pages = pagesResponse?.data || [];
const total = pagesResponse?.meta?.total;Benefits:
- Single, predictable return type for all list methods
- No need to check
Array.isArray()or normalize responses - Cleaner TypeScript types
- Metadata always accessible via
.metaproperty
TreeQueryOptions Type Correction:
TreeQueryOptions no longer incorrectly includes limit and skip parameters (which were always ignored). Tree structures use parent, populate, filter, and fields instead.
// Before (v2.x) - allowed but ignored
await cockpit.getContentTree('categories', { limit: 10 }); // ❌ TypeScript allowed this
// After (v3.0.0) - TypeScript prevents invalid usage
await cockpit.getContentTree('categories', {
parent: 'root-id', // ✅ Correct
populate: 2, // ✅ Correct
filter: { active: true } // ✅ Correct
});graphql(optional) - Required for thegraphQL()method@graphql-tools/wrap(optional) - Required for the/schemasubpackage
Contributions are welcome! Please open an issue or submit a pull request.
This project is licensed under the MIT License. See the LICENSE file for details.