@sixtyninecommerce/core
v0.10.0
Published
Transport, tenant context, error and pagination primitives shared by the sixtynine-commerce SDKs
Readme
@sixtyninecommerce/core
Transport, tenant context, errors and pagination for the sixtynine-commerce SDKs.
This package is the shared floor under @sixtyninecommerce/client
(storefront) and @sixtyninecommerce/admin
(back office). It talks to the three services a tenant runs on — the commerce engine, the CMS and
the plugins service — and hides every wire detail behind one object:
TenantContext— which channel (or organization), which language, which order scope, which location, on whose behalf. The SDK stamps whatever header each service wants; consumers never see a header name.Transport— GraphQL and REST calls with header stamping, timeouts, linked abort signals, retries that only ever fire on idempotent work, and channel re-resolution when a channel is renamed underneath you.PlatformError— one error hierarchy over three wire dialects: RFC 9457 problem bodies, VendureErrorResultunions delivered at HTTP 200, and GraphQLerrors[].Page<T>— cursor pagination with.next()andfor await (const item of page).- Idempotency keys (UUIDv7), W3C
traceparent, rate-limit budgets, deprecation notices and snake/camel case conversion.
Zero runtime dependencies. It runs anywhere fetch and Web Crypto do: Node 20+, Bun, Deno,
browsers, edge runtimes.
Install
npm install @sixtyninecommerce/coreMost consumers install @sixtyninecommerce/client or @sixtyninecommerce/admin instead and get
this package as a dependency.
Entry points
The package root exports everything. Each group also has its own subpath, so a module that needs only the error classes does not load the transport, the events client, hooks and streams with them:
| Import | What it holds |
| --- | --- |
| @sixtyninecommerce/core/errors | PlatformError and its subclasses, and the readers that build them from a response: errorFromResponse, errorFromProblem, errorFromGraphQLErrors, errorFromErrorResult |
| @sixtyninecommerce/core/transport | createTransport, tenancy (channelTenant, TenantContext), channel resolution, per-service headers, retries, rate limits, conditional reads, shared reads in flight, deprecation notices, the AI agent header (AGENT_HEADER, AgentInfo, parseAgentHeaderValue, formatAgentHeaderValue) |
| @sixtyninecommerce/core/pagination | Page<T>, createPage, the cursor helpers |
| @sixtyninecommerce/core/case | snake/camel case conversion |
| @sixtyninecommerce/core/idempotency | idempotencyKey(), IDEMPOTENCY_KEY_HEADER |
| @sixtyninecommerce/core/trace | W3C traceparent minting and parsing |
| @sixtyninecommerce/core/events | the event envelope and payload types, verifyEventDelivery, createEventHandler, the event feed and stream, toCloudEvent |
| @sixtyninecommerce/core/hooks | app hooks: the request and response contracts, createHookHandler, verifyHookRequest |
| @sixtyninecommerce/core/streams | progress streams (openProgressStream) and their event types |
| @sixtyninecommerce/core/apps | the app manifest JSON schema |
A subpath hands out the very bindings the root does: an error class imported from
@sixtyninecommerce/core/errors is the same class as the root's, so instanceof holds whichever
one each side imported. Subpaths resolve through package.json exports, so TypeScript needs
moduleResolution node16, nodenext or bundler, which the root already requires.
Usage
import {
channelTenant,
createChannelResolver,
createTransport,
NotFoundError,
} from '@sixtyninecommerce/core';
const urls = {
vendureUrl: 'https://engine.sixtyninecommerce.com',
cmsUrl: 'https://cms.sixtyninecommerce.com',
pluginsUrl: 'https://plugins.sixtyninecommerce.com',
};
const transport = createTransport({
urls,
channels: createChannelResolver({ cmsUrl: urls.cmsUrl }),
tenant: channelTenant('velvet-thread', { language: 'nl' }),
principal: { kind: 'bearer', token: process.env.API_TOKEN ?? '' },
});
try {
const data = await transport.graphql<{ product: { name: string } | null }>({
api: 'admin',
query: 'query Product($id: ID!) { product(id: $id) { name } }',
variables: { id: '42' },
});
console.log(data.product?.name);
} catch (error) {
if (error instanceof NotFoundError) {
// `error.code` is stable; `error.problem` is the raw RFC 9457 body when there was one.
}
throw error;
}Errors
Every failure is a PlatformError: ValidationError (400), AuthenticationError (401),
PermissionDeniedError (403), NotFoundError (404), ConflictError (409), BusinessRuleError
(422), RateLimitedError (429), UpstreamError (5xx) and TransportError (no response at all).
Branch on error.code — it is stable across releases — and read error.details for field-level
reasons, error.retryAfterSeconds for a wait hint and error.traceId to find the request in the
platform's audit trail.
On the GraphQL API a server-side failure is a 5xx whatever HTTP status carried it:
SERVICE_UNAVAILABLE (the engine could not reach its database) is a retryable UpstreamError 503
with retryAfterSeconds, and INTERNAL_SERVER_ERROR, INTERNAL_ERROR or an error naming no code
at all (GRAPHQL_ERROR) is a non-retryable UpstreamError 500. Neither is a BusinessRuleError:
that class is for a refusal the caller can act on.
Any other database failure arrives with a generic sentence, never the database's own text. One the
caller can act on carries the engine's code: DUPLICATE_RECORD is a ConflictError (409),
INVALID_DATA a ValidationError (400), RECORD_IN_USE a BusinessRuleError (422). A server-side
database fault is INTERNAL_SERVER_ERROR. On a write each one is flagged outcomeUnknown (below).
error.outcomeUnknown is true when a write may or may not have taken effect. Four cases set it:
The engine flags it.
SERVICE_UNAVAILABLEwithextensions.outcomeUnknown: the engine lost its database while the write may have run. Any other database failure of a write carries the same flag, whatever its code. The engine sends it at HTTP 200, never 503. ASERVICE_UNAVAILABLEerror'sstatusis 503 whatever the HTTP status was.A write went out and no answer came back. A write is a GraphQL mutation, or a REST
POST,PUT,PATCHorDELETEto the CMS or plugins that the transport does not repeat. A keyed write on a client withretryIdempotentWritesis repeated under its key instead. The cases are:- a timeout (
REQUEST_TIMEOUT); - your own signal aborting it after it was sent (
REQUEST_ABORTED): a Cancel button, an unmount,AbortSignal.timeout(n), a forwardedrequest.signal. A signal that was already aborted when you made the call is not flagged, because nothing was sent; - a dropped connection (
NETWORK_ERROR); - a 5xx no platform component coded, such as a proxy's 502 or 504, or an empty 503;
- a 2xx body nobody can read (GraphQL only: a REST 2xx is the service's success).
The error keeps its class, code and status. One exception: a network failure that provably happened before the request left, such as a DNS failure or a refused connection, stays retryable. Only Node's
fetchcan prove that.- a timeout (
IDEMPOTENCY_OUTCOME_UNKNOWN: a spent idempotency key whose first call may have moved money.POS_CARD_OUTCOME_UNKNOWN: a till's card charge that the payment provider may have taken without answering. The terminal may still be prompting the customer, and the charge left no payment on the order, so reading the order back cannot settle it. Look at the terminal and at the provider before taking another card payment for that sale.
Such an error is never retryable and carries no wait hint. Read the resource back and reconcile before doing anything again. Repeating the call blindly is how a write lands twice. A query is never flagged, because asking again cannot write twice.
A write sent with an idempotency key is the exception to "reconcile first". When its answer never
came back, the key is not spent and the first call may still be running, so repeat it under the
SAME key: the platform answers with the first call's result, IDEMPOTENCY_IN_PROGRESS (wait, then
repeat), or IDEMPOTENCY_OUTCOME_UNKNOWN. Only that last code means the key is spent: reconcile,
and use a NEW key only once you know the first write did not land. A new key after a timeout can
apply the write twice.
The flag follows each code's own contract, not its spelling. INVOICING_OUTCOME_UNKNOWN is NOT
flagged. It means "the document is not settled in the ERP yet: repeat the same action later". That
is safe, because the platform asks the ERP first and never books twice.
Pagination
for await (const item of await listSomething()) {
// walks every page; `page.next()` is there when you want one page at a time
}Tenancy
TenantContext says which channel (or organization) a call is addressed to, in which language, on
whose behalf; the SDK turns it into whatever each service reads it from — a header on one, a query
parameter on another. A call may override the client's default for itself.
UNSCOPED_TENANT is the exception: a session login, and the me that reads back which channels
the session may address, happen before the caller knows its tenant. Those go out with no tenant
header at all, and must opt in with allowUnscoped. Every other call on an unscoped tenant throws
TENANT_REQUIRED — an administrator with several channels has to say which one they mean, and the
server never guesses.
A channel code becomes the channel's id and token through the transport's channels resolver.
createChannelResolver asks the CMS (GET {cms}/channels/by-code/:code, cached for 5 minutes).
A client that already holds the list of channels it may address, and must not depend on the CMS,
uses createChannelListResolver instead: an admin app hands it its session's me().channels.
import { createChannelListResolver } from '@sixtyninecommerce/core';
const channels = createChannelListResolver({ load: async () => (await me()).channels });The list is loaded once for every code and again after an invalidate, or for a code it does not
name. A code that a fresh list does not name either is CHANNEL_NOT_FOUND. When a service answers
CHANNEL_NOT_FOUND for a resolved channel, the transport invalidates, loads the list again and
repeats the call once. The answer's language, currency, tax display and custom fields are '',
'', false and {} unless the list carries them, so read those from the store's settings.
Case conversion
The REST services speak snake_case and this SDK speaks camelCase, so request and response
bodies are converted. Some values are data rather than structure — a form's settings, an
integration's credentials, a field's validation — and converting those renames a merchant's
own keys. Name them in preserveKeys and their subtree is left alone — the key itself is still
renamed, because the key is DTO shape and only what is inside the bag is the merchant's:
import { PLATFORM_PASSTHROUGH_KEYS } from '@sixtyninecommerce/core';
transport.rest('cms', call, { preserveKeys: PLATFORM_PASSTHROUGH_KEYS });PLATFORM_PASSTHROUGH_KEYS lists the platform's stored-verbatim columns. It is not applied
automatically: a name means different things on different endpoints, so the module that owns an
endpoint decides.
Retries
Only idempotent work is retried: GET requests and GraphQL query documents. Network failures,
502/503/504 (a GraphQL SERVICE_UNAVAILABLE too, even beside partial data at 200, unless it says
outcomeUnknown) and 429 are retried — after the service's wait hint when it sent one (Retry-After, or the error's own
retryAfterSeconds), capped at maxDelayMs, otherwise with exponential backoff. Nothing else
ever is, and an aborted request never is.
A REST write is not retried by default, even with an idempotencyKey. CMS and plugins do
deduplicate on the header — a repeat under the same key replays the first response, and the same
key with a different body is refused with 409 — but only on the routes marked @Idempotent():
today POST /forms, PUT /forms/by-slug/:slug and POST /redirects. Everywhere else the key is
recorded on the wire and acts on nothing, so a retry there is a second write: a second row, a
second submission, a second notification email. This transport cannot tell the two apart, so pass
retryIdempotentWrites: true only for a client whose writes all land on routes that honour it.
A GraphQL mutation is never retried, idempotency key or not. On a Shop API call the
idempotencyKey goes out as an Idempotency-Key header, and addPaymentToOrder deduplicates on
it — but an engine that predates that ignores the header, so a transport retry there could replay a
payment the PSP already took. The Admin API gets no header: its key is a mutation argument, so pass
it in variables if the mutation accepts one. Either way, drive the repeat from the module that
knows it is safe.
Deadlines and traces per call
Every call has a deadline: timeoutMs on the transport (15 seconds unless configured), or on the
call itself, which wins. It covers the request and reading its answer; a value that is not above 0
and at most MAX_TIMEOUT_MS is refused (TIMEOUT_INVALID) before anything is sent. A call cut by
its deadline is REQUEST_TIMEOUT, on React Native's expo/fetch too.
Every call also carries a W3C traceparent, minted per call unless the call names one. Name the
same one on every call of a user flow, and the platform's audit trail shows them as one trace:
import { mintTraceparent } from '@sixtyninecommerce/core';
const flow = mintTraceparent();
await transport.graphql(refund, { traceparent: flow, timeoutMs: 30_000 });
await transport.graphql(readBack, { traceparent: flow });A traceparent that is not a valid header is refused (TRACEPARENT_INVALID) before anything is
sent.
Conditional reads
createTransport({ conditionalReads: true }) remembers each public REST read's ETag and body,
sends If-None-Match the next time, and answers a 304 from what it remembered (spec §6.8). Off
by default; @sixtyninecommerce/client turns it on for its CMS/plugins transport.
- Only an anonymous REST
GETis eligible. A call with a bearer skips the cache entirely, and no GraphQL call is ever cached. - The key is everything the request sends except
traceparent,Idempotency-Keyandx-buyer-ip: the full URL (query sorted), every tenant, language, head, actor and storefront-token header, and thefetchInitpassthrough. AfetchInitthat is not plain data is never cached. Stored entries are shared between shoppers; requests are not (a request carries its shopper's rate-limit budget). trueshares one process-wide cache perfetchfunction (500 entries / 5 MB, least recently used first out);{ maxEntries?, maxBytes? }gives the transport a cache of its own.- Stored: a 200 with an
ETag, a JSON body, nono-storeand noVary: *. Errors never are. Every hit is parsed afresh, and identical concurrent reads share one request (each caller keeps its own signal and deadline; the shared request stops when the last caller leaves, or at the deadline of the caller that started it). - It sits ABOVE the
fetchit is given. Afetchwith a cache of its own that keys on request headers (Next.js's data cache) misses wheneverIf-None-Matchis added: turn one of the two off.
Read admission
createTransport({ readAdmission: { limiter, key } }) caps the GraphQL reads in flight per key and
queues the rest before anything is sent. Off by default.
import { createReadAdmission, createTransport } from '@sixtyninecommerce/core';
// One limiter for the process: every transport handed it shares its caps.
const shopReads = createReadAdmission({ limit: 8, maxQueueMs: 30_000 });
const transport = createTransport({
urls, // { vendureUrl, cmsUrl, pluginsUrl }
// …principal, tenant, channels
readAdmission: {
limiter: shopReads,
// Signed-in reads wait per session; anonymous ones go out at once.
key: ({ api, bearer, tenant }) =>
api === 'shop' && bearer ? JSON.stringify([urls.vendureUrl, tenant.scope, bearer]) : null,
},
});- Only reads wait. The transport asks
keyabout each attempt of a GraphQL query, with the bearer that attempt carries. Mutations, uploads and sign-ins (startsSession) are never asked and never wait. REST calls and event streams are not admitted. Anullkey sends the read at once. - Queue time is not request time. A read is admitted before its deadline is armed, so a wait
never counts toward
timeoutMsand never triggers a retry. Slots go out in the order the reads were made. - A slot covers one attempt. It is held for the request and reading its answer, and comes back however the attempt ends: an answer, a refusal, a timeout or an abort. A retry backs off holding no slot, then waits behind the reads already queued.
- Waiting is bounded. A read still queued after
maxQueueMs(30 seconds unless set) is refused withREQUEST_QUEUE_TIMEOUT, a non-retryableTransportError. Nothing was sent, so it is neveroutcomeUnknown. A signal that fires while the read is queued isREQUEST_ABORTED, and nothing is sent then either. - Keys are shared through the limiter. Hand the same limiter to every transport whose reads should share a cap, such as one client per request for one session. A key with nothing in flight or waiting is forgotten. The limiter keeps keys in memory only and never puts one in an error. A key built from a bearer is a credential, so never log it yourself.
limitmust be a whole number of at least 1 (default 8).maxQueueMsmust be above 0 and at most2³¹ − 1(default 30 s). Anything else, and areadAdmissionthat is not{ limiter, key }, is refused withREAD_ADMISSION_INVALID. A key function that answers something other than a string ornullfails the call withREAD_ADMISSION_INVALID, and nothing is sent.
Shared reads in flight
createTransport({ dedupeReads: true }) lets identical GraphQL queries in flight share one
request: a page whose layout, body and metadata each ask for the same product sends it once. Off by
default; anything but true or false is refused when the transport is built
(DEDUPE_READS_INVALID).
- Two queries share only when their requests are the same in everything the server reads. That
covers the endpoint, the language and currency on the URL, the document and its variables byte
for byte, the bearer, the channel token, every other header except a minted
traceparent, and thefetchInitpassthrough. Two shoppers, channels, languages or currencies never share. - Every transport built on the same
fetchshares, process-wide: a client built per request shares with the client built for another part of the same render. - Each caller parses its own copy, keeps its own signal, retries on its own, and gets its own
error (with the trace id of the request that went out). The shared request is aborted when every
caller has left, or at the deadline of the caller that started it. A caller that joined with a
longer deadline then fails at that earlier one:
REQUEST_TIMEOUTwherefetchrejects an abort as anAbortError(Node, browsers), its message still naming its owntimeoutMs. Nothing is kept: a query after the answer was read sends again. - Never shared: mutations, uploads, sign-ins (
startsSession), calls with an idempotency or order access key, and afetchInitthat is not plain data. - A write through the transport (a mutation or a REST write) stops its session's reads in flight from being joined when it goes out and again when it is answered. A read made after a write never gets an answer from before it.
- A session token that the answer to a read sent with no bearer hands out (Vendure mints one for
activeOrderand other Owner-allowed reads) goes only to the caller who sent the request. Every other caller gets the same answer without it, and mints a session of its own on its next Owner-allowed call, so two guests are never handed one session. - The key is what the transport sends. A
fetchthat adds a credential per caller (a cookie it forwards) must be one function per caller, or keep this off. - A caller that joins never calls
fetch, so afetchthat records each render's requests sees only the request that went out. On Next.js's patchedfetch, a render that joins another render's request records none of that request's cache tags (fetchInit.next.tags). ArevalidateTagof a tag only that request carried then never reaches the joining page, which keeps the old answer until it is revalidated some other way. Keep this off for pages cached by tag. - With read admission on, a shared request holds one slot: the slot of the caller that started it, until the request settles, even when that caller leaves first. A caller that joins one in flight neither queues nor holds a slot.
Verifying webhook deliveries
Every platform-event delivery is signed twice: with the Standard Webhooks
headers (webhook-id, webhook-timestamp, webhook-signature) and with the older
x-webhook-signature: sha256=<hex>. verifyEventDelivery checks whichever set arrived — the
standard one wins when present — then the replay window and the envelope shape:
import { verifyEventDelivery } from '@sixtyninecommerce/core';
export async function POST(request: Request) {
const raw = await request.text(); // the exact bytes: never parse and re-stringify
const result = await verifyEventDelivery(raw, request.headers, process.env.EVENTS_SECRET!);
if (!result.ok) return new Response(result.reason, { status: result.status });
// result.envelope.type, result.envelope.data …
return new Response(null, { status: 204 });
}The secret is the one shown when the subscription was created. Its Standard Webhooks spelling is
whsec_ + base64 of that string's UTF-8 bytes, so a stock Standard Webhooks library verifies the
same deliveries; verifyEventDelivery accepts either spelling, and a list of secrets for a rotation
overlap. webhook-id is the event id and stays the same across retries: deduplicate on it.
Rotating the secret. admin.events.subscriptions.rotateSecret(id) answers a new secret once.
For 24 hours after (previousExpiresAt) every delivery is signed with both: webhook-signature
carries v1,<new> v1,<old>, and the legacy x-webhook-signature stays on the old secret. Deploy
the receiver with [newSecret, oldSecret], then drop the old one after previousExpiresAt.
Receiving events
createEventHandler is a whole webhook endpoint as one (request: Request) => Promise<Response>.
It verifies the delivery (see above) and calls the handler registered for the event's type, with
data typed by that type's payload contract:
import { createEventHandler } from '@sixtyninecommerce/core';
export const POST = createEventHandler({
secret: process.env.EVENTS_SECRET!,
on: {
'order.status_changed': async (event) => {
if (event.data.payment_status === 'paid') await fulfil(event.data.order_id);
},
},
onUnknown: (event) => console.info('unhandled', event.type),
});It answers 200 once your handler resolves, 401/400 for a delivery that does not verify, and 500
when your handler throws, so the platform retries. Every payload type is exported
(PlatformEventPayloadByTypeV1, OrderStatusChangedV1, …); isEventType(event, type) narrows
an envelope you got some other way.
To pull instead of being pushed to, use createEventsClient(transport) (or admin.events in
@sixtyninecommerce/admin), with a token holding ReadPlatformEvents. poll({ after }) reads
one page forward, poll({ from: 'now' }) starts at the head (it needs a platform that serves
GET /events/head, and throws NotFoundError on one that does not), pollAll() is a never-ending
async iterator, and replay({ fromLogSeq }) rereads from a position. Store nextCursor after
every page.
To have events pushed down one connection instead, use stream() (Server-Sent Events):
const stream = events.stream({ after: savedCursor, types: ['order.*'] });
for await (const event of stream) {
await handle(event);
await save(stream.cursor); // a stream cursor is a feed cursor: poll({ after }) resumes from it
}It reconnects by itself from the last event it yielded and never yields one twice. A credential's
live streams are limited (one by default): opening another closes the oldest with
EventStreamClosedError (superseded). A revoked credential ends it with EventStreamClosedError (revoked). Neither
case is retried. Events reach the log through relays that tick once a minute, so they arrive up to
about a minute after the change. For a native EventSource, which cannot send Authorization,
streamTicket() returns a single-use, 60-second ticket and a ready url.
File uploads
transport.rest() sends multipart/form-data when the call carries multipart instead of body:
await transport.rest('cms', {
method: 'POST',
path: '/assets/upload',
multipart: { fields: { folder_id: folderId }, files: [{ field: 'file', file }] },
});Field names, values and file names go out exactly as given (no case conversion), fetch sets the
boundary, and the call is never retried. A file part that is not a Blob or File is refused
before anything is sent (MULTIPART_FILE_INVALID). Unless the call names a timeoutMs, it gets
MULTIPART_TIMEOUT_MS (5 minutes).
Progress streams
openProgressStream() follows one long-running job over its server-sent event stream. You name a
kind from a closed set (PROGRESS_STREAM_KINDS) and the job's id, never a path:
for await (const event of openProgressStream(
transport,
{ kind: 'price-import-ai-conversion', id: conversionId },
{ signal }
)) {
if (event.type === 'progress') show(event.message);
else if (event.type === 'result') use(event.result);
else fail(event.code, event.message);
}The iteration ends after the terminal event. It throws PROGRESS_STREAM_STALLED after
idleTimeoutMs (45 s) of silence, PROGRESS_STREAM_CLOSED if the stream ends before a terminal
event, and REQUEST_ABORTED when signal fires.
| Kind | Id | Events (terminal in bold) | Reconnects |
|---|---|---|---|
| price-import-ai-conversion | the upload's conversionId | progress, result, error | no: the job lives in the connection |
| pipeline-run | a pipeline run's id | progress (per-entity counts), complete, error, cancelled, paused | yes |
| pipeline-discovery | a discovery's id | progress (a percentage), complete, error | yes |
A kind that reconnects reopens a connection that closed, broke, stalled or could not open (a
network error, 5xx, 429) before its terminal event: at most maxReconnects times in a row
(default 5; an event received resets the count), waiting reconnectDelayMs (1 s) doubling to
30 s, telling onReconnect each time. The server answers an ended job with its ended state on
every connection, so a reopen still reads the outcome; only once the reopens are spent does the
iteration throw PROGRESS_STREAM_CLOSED / PROGRESS_STREAM_STALLED. Do not wrap it in a reconnect
loop of your own. A refusal is never retried: 401, 403, 404 (the plugins service's answer for a
run or discovery outside the caller's store), and PROGRESS_STREAM_REFUSED, the same refusal as
an older plugins service sends it, inside the opened stream.
License
MIT
