@propeller-commerce/propeller-sdk-v2
v0.11.1
Published
TypeScript GraphQL client for Propeller eCommerce V2 Platform - works with React, Vue, Angular and all JavaScript frameworks
Readme
Propeller V2 SDK
A TypeScript GraphQL client for the Propeller Commerce Platform. Ships a typed service for every supported query and mutation — with the GraphQL documents and fragments generated and bundled per operation, so consumers don't write or maintain GraphQL themselves and only the operations they import are pulled into their bundle.
- Framework-agnostic. Ships dual ESM + CJS builds. Works with Next.js, Vite, Webpack, Node, and bare browsers.
- No runtime GraphQL parser. Fragments are inlined at build time; the SDK does not depend on the
graphqlpackage at runtime. - Type-safe. Full TypeScript definitions for every enum, input, and response type. (Types describe the entity's full shape; an operation populates only the fields it selects — see "Return types are the named type; only selected fields are populated" below.)
- Secure-by-default config. Defaults to proxy mode so API keys can stay server-side.
Installation
npm install @propeller-commerce/propeller-sdk-v2Quick start (v0.10.0)
import { createClient, productService, ProductStatus, Format, Fit } from '@propeller-commerce/propeller-sdk-v2';
// Initialize once in your app entry point.
const client = createClient({
endpoint: 'https://your-proxy.example.com/api/graphql',
securityMode: 'proxy', // recommended — keep API keys server-side
clientId: 'my-storefront', // optional, sent as X-Client-ID header
defaultLanguage: 'NL', // optional; service calls without an explicit `language` fall back to this
getAccessToken: () => localStorage.getItem('access_token') ?? undefined,
});
// Factory pattern (preferred, tree-shakes).
const products = productService(client);
const result = await products.getProducts({
input: {
page: 1,
offset: 20,
term: 'laptop',
statuses: [ProductStatus.A, ProductStatus.N],
language: 'NL',
},
imageSearchFilters: { page: 1, offset: 1 },
imageVariantFilters: {
transformations: {
name: 'product_thumb',
transformation: {
format: Format.WEBP,
height: 300,
width: 300,
fit: Fit.BOUNDS,
},
},
},
language: 'NL',
});Upgrading from v0.9.x
The class form continues to work in v0.10.0 as a thin backward-compatible wrapper:
import { GraphQLClient, ProductService } from '@propeller-commerce/propeller-sdk-v2';
const client = new GraphQLClient({ endpoint: '/api/graphql' });
const productService = new ProductService(client);
const product = await productService.getProduct({ productId: 1 });See MIGRATION-0.10.0.md for the full upgrade guide.
Configuration
GraphQLClientConfig accepts:
| Field | Type | Default | Notes |
| --- | --- | --- | --- |
| endpoint | string | — | Required. Direct API URL or your proxy endpoint. |
| securityMode | 'proxy' \| 'direct' | 'proxy' | Use 'proxy' in production. 'direct' exposes API keys client-side. |
| proxyEndpoint | string | endpoint | Override for proxy URL when you want endpoint to point at the upstream. |
| clientId | string | — | Sent as X-Client-ID in proxy mode. |
| apiKey | string | — | Direct mode only. Ignored in proxy mode (with a warning). |
| orderEditorApiKey | string | — | Direct mode only. Used for the mutations in orderEditorMutations. |
| orderEditorMutations | string[] | ['orderSetStatus', 'passwordResetLink', 'triggerQuoteSendRequest', 'triggerOrderSendConfirm'] | Override to route additional mutations through orderEditorApiKey. |
| headers | Record<string, string> | — | Merged into every request. |
| timeout | number | 30000 | Request timeout (ms). Triggers AbortController. |
| debug | boolean | false | Gates internal [GraphQL Client] logs. Config-validation warnings always fire. |
| getAccessToken | () => string \| undefined \| Promise<string \| undefined> | reads localStorage['access_token'] in browser | Use this for SSR (getServerSession, HTTP-only cookies) or in-memory token stores. |
| defaultLanguage | string | — | ISO 639-1 tag (e.g. 'NL'). Service methods that take an optional top-level language and don't receive one fall back to this value before sending the request. An explicit language on the call always wins. |
| throwOnPartialErrors | boolean | false | On a partial response (data and errors), services return the data and debug-log the errors. Set true to throw GraphQLOperationError instead. client.execute() never throws regardless. |
Proxy contract
When securityMode: 'proxy', the SDK posts to proxyEndpoint || endpoint with:
- Method:
POST - Headers:
Content-Type: application/jsonX-Client-ID: <clientId>(if configured)Authorization: Bearer <token>(ifgetAccessToken()returns a token)- Any
headersyou supplied in config
- Body:
{ query, variables, operationName }
Your proxy is expected to:
- Attach the upstream
apikeyheader (andorderEditorApiKeyfor the operations you allow-list). - Forward the request to the upstream Propeller GraphQL endpoint.
- Return the upstream JSON response untouched.
A minimal Node/Edge handler looks like:
export async function handler(req: Request) {
const upstream = await fetch('https://api.helice.cloud/v2/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
apikey: process.env.PROPELLER_API_KEY!,
},
body: await req.text(),
});
return new Response(await upstream.text(), {
status: upstream.status,
headers: { 'Content-Type': 'application/json' },
});
}Services
Services group related GraphQL operations. Each service is exposed two ways:
- Factory function (preferred):
cartService(client)returns an object with the service methods. Tree-shakeable — only the operations you actually call are bundled. - Class form (backward-compatible):
new CartService(client)returns a thin wrapper around the factory. Same method names. Most signatures are unchanged from v0.9.x; methods whose operation needs more variables than the old signature could express now take a singlevariablesobject (see Operation variables below).
Core services include productService, orderService, cartService,
userService, paymentService, categoryService, attributeService,
discountService, bundleService, crossupsellService, companyService,
taxService, shipmentService, warehouseService, businessRuleService —
52 in total covering catalog, cart, order, user, B2B, media, and admin
domains. See the documentation
for the full list.
import { cartService } from '@propeller-commerce/propeller-sdk-v2';
const carts = cartService(client);
const cart = await carts.getCart({
cartId: '...',
language: 'NL',
imageSearchFilters: { page: 1, offset: 1 },
imageVariantFilters: { /* ... */ },
});Operation variables and return shapes
Each service method takes the variables its GraphQL operation actually
declares. Methods that need more than a single input take one variables
object argument:
import { productService, type ProductUpdateVariables } from '@propeller-commerce/propeller-sdk-v2';
const products = productService(client);
// `productUpdate` declares $productId: Int! and $input: UpdateProductInput!
const vars: ProductUpdateVariables = {
productId: 123,
input: { /* UpdateProductInput */ },
};
await products.updateProduct(vars);Two interface families, by design. You'll see two naming shapes and it's worth knowing why:
<Op>Variables(e.g.ProductUpdateVariables) — generated from the GraphQL operation. Field names and required/optional status mirror the operation's declared variables exactly. These are emitted by the build and re-exported through an explicit public barrel (generated/operationVariablesPublic), so they can never collide with the hand-authored names below.- Hand-authored interfaces use both suffixes —
<Op>QueryVariables(e.g.ProductQueryVariables) and plain<Op>Variables(e.g.CartStartVariables), the plain form being the majority of the kept set. They live in the service file, kept because they carry per-field JSDoc and are the stable names existing consumers already import.
The suffix is therefore not a reliable generated-vs-hand-authored signal —
plenty of hand-authored names also end in plain <Op>Variables. The committed
manifest scripts/.kept-service-variables.json is the single source of truth
for which names are hand-authored; a drift guard (npm run validate) fails the
build if the generated set and the kept set ever overlap.
You don't have to track which is which day-to-day — your editor resolves the correct type from the method signature. The split is documented here only so the two names aren't a mystery.
Methods whose operation takes a single value keep their direct signature
(e.g. getProductSurcharges(productId), loginService.login(input)).
Operations that declare no variables take no argument
(logoutService.logout()).
SDK-defaulted variables. Two variables are filled in for you when omitted:
language (falls back to the client's defaultLanguage) and
imageVariantFilters (defaults to { transformations: [] }). Both are
surfaced as optional on the interfaces even where the operation declares
them non-null, because the SDK guarantees a valid value reaches the wire — so
a plain getProduct({ productId }) just works. An explicit value always wins.
Return types are the named type; only selected fields are populated.
A method's return type is the operation's named GraphQL type (e.g.
updateProduct(...) → Promise<Product>). That type describes the shape of
the entity, not a promise that every field is filled. Each operation only
selects — and the server only returns — the fields its GraphQL document asks
for. updateProduct selects just productId, so you get a Product with
productId set and the other fields absent (undefined). This is normal
GraphQL partial-response behaviour: read only the fields the operation
fetches, exactly as you would with any GraphQL client. The strongly-typed
Product is a convenience for the fields you do get back, not a guarantee
that the whole entity was fetched.
Known, accepted limitation. The return type is the full named interface even though a given operation populates only a subset — so a field the interface declares non-optional (e.g.
Product.categoryPath) can beundefinedat runtime, and TypeScript will not flag it. This is a deliberate trade-off, not a bug: the SDK does not generate per-operation result types (Pick<>/<Op>Result) or widen returns toPartial<T>. Treat any field outside the operation's selection set as possibly absent and use optional chaining (product.price?.gross). To see exactly which fields an operation selects, read its document insrc/generated/operations/(or the API docs).
Direct GraphQL access
For ad-hoc queries that aren't covered by a service:
import { GraphQLClient } from '@propeller-commerce/propeller-sdk-v2';
const client = new GraphQLClient({
endpoint: 'https://api.propeller.com/graphql',
securityMode: 'direct',
apiKey: 'your-api-key',
});
// Object-form arguments (preferred):
const result = await client.execute({
query: `query GetProducts($offset: Int!) {
products(input: { offset: $offset }) {
id
name
}
}`,
variables: { offset: 10 },
});
// Or higher-level helpers that throw on GraphQL errors:
const data = await client.query<{ viewer: { id: number } }>(
`query Viewer { viewer { id } }`
);Per-operation fetch hints (Next.js cache, etc.)
v0.11.0 added an optional fetchOptions field on GraphQLOperation. Use it
to attach transport-level cache hints to a single call — without these, the
SDK has no opinion about caching:
import { createClient, type GraphQLFetchOptions } from '@propeller-commerce/propeller-sdk-v2';
const client = createClient({ endpoint: '...' });
// Anonymous SSR catalog fetch, cached by Next.js for 5 min and bustable by tag.
const product = await client.execute({
query: getProductDocument,
variables: { productId: 42 },
operationName: 'GetProduct',
fetchOptions: {
next: {
revalidate: 300,
tags: ['catalog', 'product', 'product:42'],
},
},
});The shape happens to match Next.js's fetch extension; the SDK itself doesn't
depend on Next. fetchOptions is a transport hint — it is never serialised
into the GraphQL request body, so two calls to the same operation with
different tags correctly hit the same Next data-cache entry. See
MIGRATION-0.11.0.md for the full pattern and the
reasoning behind the narrow type.
Error handling
Services and the query / mutate / queryByName / mutateByName helpers
throw GraphQLOperationError only when the response is a hard failure —
the server returned errors and no data. When the server returns a partial
response (GraphQL's normal contract: data present alongside errors), the
data is returned and the errors are surfaced through the client's debug log
rather than thrown. If you need the raw errors array in the partial case,
call client.execute() directly — it never throws and always returns the raw
{ data, errors }.
import { GraphQLOperationError } from '@propeller-commerce/propeller-sdk-v2';
try {
const product = await productService.getProduct({ productId: 1 });
} catch (err) {
if (err instanceof GraphQLOperationError) {
// err.errors — GraphQLErrorEntry[]
// err.operationName — string | undefined
// err.variables — Record<string, any> | undefined
// err.document — the exact GraphQL query/mutation string that failed
console.error('Operation failed:', err.operationName, err.errors);
console.error('Query was:', err.document);
} else {
// Network, HTTP, or timeout error
console.error('Request failed:', err);
}
}err.document carries the exact GraphQL document that produced the error —
useful for logging the failing query verbatim (e.g. when diagnosing schema
drift) without trawling the source.
HTTP errors (non-2xx) include the response body in the thrown message (truncated to 500 chars) so upstream GraphQL parse errors surface clearly.
Authentication
In proxy mode, every request runs config.getAccessToken() and attaches Authorization: Bearer <token> when a token is returned. The provider may be sync or async.
// Browser (default): reads localStorage['access_token']
const client = createClient({ endpoint: '/api/graphql' });
// SSR (Next.js): read from cookies on each request
const client = createClient({
endpoint: '/api/graphql',
getAccessToken: async () => {
const session = await getServerSession();
return session?.accessToken;
},
});
// In-memory store
let token: string | undefined;
const client = createClient({ endpoint: '/api/graphql', getAccessToken: () => token });client.setAccessToken(token) and client.clearAccessToken() write to / clear localStorage only when the default provider is in use; with a custom getAccessToken you manage the storage yourself.
client.isAuthenticated() is async and resolves to true iff the configured provider yields a token.
Legacy / deprecated APIs
The following are still exported from the package root for backward
compatibility but are deprecated and will be removed in a future release.
They emit a one-time console.warn on first use and carry @deprecated JSDoc
so your IDE flags call sites.
| Deprecated | Use instead |
| --- | --- |
| createGraphQLClient(config) | createClient(config) |
| initializeClient(config) + getClient() (global singleton) | const client = createClient(config) and pass client explicitly to service factories |
// ❌ Deprecated — global singleton
import { initializeClient, getClient, productService } from '@propeller-commerce/propeller-sdk-v2';
initializeClient({ endpoint: '/api/graphql' });
const products = productService(getClient());
// ✅ Preferred — explicit client
import { createClient, productService } from '@propeller-commerce/propeller-sdk-v2';
const client = createClient({ endpoint: '/api/graphql' });
const products = productService(client);Type definitions
Every response and input type is a plain TypeScript interface. Service
methods return plain JSON values typed as those interfaces — what the server
sends, accessed by field. Read fields directly; localized arrays use the
getLocalized helper (below).
import type { Product, CreateProductInput } from '@propeller-commerce/propeller-sdk-v2';
import { ProductStatus } from '@propeller-commerce/propeller-sdk-v2';
const status: ProductStatus = ProductStatus.A;
const input: CreateProductInput = {
/* fields */
};
// Typed as `Product`; only the fields the `product` operation selects are
// populated — see "Return types" above. Read with optional chaining.
const product = await productService(client).getProduct({ productId: 1 });
product.id;
product.sku;
product.price?.gross;Localized fields
For localized arrays (e.g. product.names: LocalizedString[]), use the
getLocalized helper:
import { getLocalized } from '@propeller-commerce/propeller-sdk-v2';
const name = getLocalized(product.names, 'EN', 'NL'); // EN, fall back to NL, then first
const desc = getLocalized(product.descriptions, locale); // no fallbackEnum imports
Enums are top-level exports. Use direct or namespace imports:
// Direct:
import { ProductStatus } from '@propeller-commerce/propeller-sdk-v2';
const status = ProductStatus.A;
// Namespace (preferred by the audited consumer apps):
import * as Enums from '@propeller-commerce/propeller-sdk-v2';
const status = Enums.ProductStatus.A;Serialization
Responses are plain objects, so JSON.stringify / JSON.parse roundtrips
(Redux DevTools, IndexedDB, SSR hydration, localStorage) work cleanly — no
rehydration step.
Development
# Install
npm install
# Build (runs fragment inliner + dual ESM/CJS compile)
npm run build
# Typecheck without emit
npm run typecheck
# Test
npm testThe build pipeline:
npm run build:graphql—scripts/build-graphql-bundle.jsreads every file undersrc/graphql/, transitively inlines fragment spreads, and writes pre-resolved strings tosrc/generated/.npm run build:cjs— emitsdist/cjs/(CommonJS).npm run build:esm— emitsdist/esm/(ES modules).
The graphql package is only required at build time and lives in devDependencies.
Schema-type drift guard
src/type/*.ts and src/enum/*.ts are hand-authored. npm run check:type-drift
(part of npm run validate, and a CI step) projects the upstream GraphQL
schema and fails the build when the committed types/enums structurally diverge
from it — a missing/extra field, an enum-value change, a nullability flip, or a
generated operation that selects a deprecated field.
Two rules to know:
- Deprecated schema members are intentionally omitted. A field or enum
value the schema marks
@deprecated(e.g.Product.mediaImages→ "Deprecated in favor ofmedia.images") is dropped from the reference projection. Its absence from the SDK is the correct state and is never a finding. Don't add deprecated members back, and don't select them insrc/graphql/**. scripts/.schema-drift-exceptions.jsonis a reviewed debt ledger. Pre-existing drift is baselined there so the guard catches only new divergence. The file should shrink over releases. To rebaseline after a deliberate schema refresh:node scripts/build-schema-drift-baseline.js, then review thegit diff— every entry must be an explainable deviation.
It is offline-first: it uses the committed tests/integration/schema.snapshot.json
when no live schema.json is present, so CI and forks pass without
credentials (it warns, never fails, when the snapshot is >60 days old).
Documentation
See the documentation site
for guides and the full API reference. The site is a Docusaurus app in
docs/; guide pages live in docs/content/** and the API reference
is generated from the source JSDoc.
License
MIT — see LICENSE.
