@classytic/carrier
v0.1.1
Published
Framework-agnostic multi-carrier shipping primitives — unified CarrierAdapter port, rule-based router, parallel rate shopping, fallback chains. Pure TypeScript, no Mongo, no HTTP. Wire any carrier (in-house, 3PL, global aggregator) behind one contract.
Downloads
308
Readme
@classytic/carrier
Framework-agnostic multi-carrier shipping primitives — unified CarrierAdapter port, rule-based router, parallel rate shopping, fallback chains. Pure TypeScript, no Mongo, no HTTP.
Wire any carrier — in-house fleet, BD 3PL (Pathao, RedX, Steadfast), global aggregator (EasyPost, Shippo), CSV-drop courier, same-day gig (DoorDash, Uber Direct) — behind one contract. The router picks the right one per shipment.
Install
npm install @classytic/carrier @classytic/primitivesNo Mongoose, no Mongo, no HTTP library — this package is pure types + logic.
The 30-second version
import {
CarrierRouter,
CarrierRegistry,
InMemoryCarrierAdapter,
} from '@classytic/carrier';
// Every carrier — in-house or 3rd party — implements the SAME interface.
const pathao = new InMemoryCarrierAdapter({
code: 'pathao',
capabilities: { cod: true, multiPackage: true, rateShopping: true },
quote: () => [{ service: 'next_day', amount: { amount: 500, currency: 'BDT' }, estimatedDays: 1 }],
});
const easypost = new InMemoryCarrierAdapter({
code: 'easypost',
capabilities: { international: true, multiPackage: true, rateShopping: true },
quote: () => [{ service: 'express', amount: { amount: 3500, currency: 'BDT' }, estimatedDays: 5 }],
});
const router = new CarrierRouter({
registry: new CarrierRegistry([pathao, easypost]),
rules: [
{ carrier: 'pathao', priority: 10, match: { country: 'BD' } },
{ carrier: 'easypost', priority: 1, match: { predicate: (s) => s.originCountry !== s.destinationCountry } },
],
ranking: 'priority',
});
// Rate shop (parallel across eligible carriers)
const quotes = await router.rateShop(shipment, ctx);
// Buy a label (fallback chain baked in)
const { label, attempts } = await router.purchase(shipment, ctx);
// Track
const tracking = await router.track(
{ carrierCode: label.carrierCode, trackingNumber: label.trackingNumber },
ctx,
);The unified CarrierAdapter port
One interface, every carrier:
interface CarrierAdapter {
readonly code: string;
readonly capabilities: CarrierCapabilities;
quoteShipment(input: ShipmentInput, ctx: OperationContext): Promise<Quote[]>;
buyLabel(input: BuyLabelInput, ctx: OperationContext): Promise<ShipmentLabel>;
track(ref: TrackingRef, ctx: OperationContext): Promise<TrackingResult>;
// Optional — probed via capabilities
verifyAddress?(address, ctx): Promise<AddressVerification>;
voidLabel?(ref, reason, ctx): Promise<void>;
ingestWebhook?(payload, headers): NormalizedWebhookEvent[];
}Write one adapter per carrier. Router doesn't care which one runs.
Routing model
Two pieces: a CarrierRegistry (adapter catalog) and a list of ServiceRules (eligibility + priority).
interface ServiceRule {
carrier: string; // adapter code in the registry
priority?: number; // higher wins; default 0
name?: string;
match?: {
country?: string | string[]; // destination; '*' = any
originCountry?: string | string[]; // origin
postalPrefix?: string | RegExp | (string | RegExp)[];
weightGrams?: { min?: number; max?: number };
service?: ServiceLevel | ServiceLevel[];
codRequired?: boolean;
tags?: string[];
predicate?: (shipment) => boolean; // escape hatch
};
}The predicate escape hatch handles anything declarative match can't express — e.g., export-only lanes, same-day during business hours, weight-tier crossover.
Ranking strategies
ranking: 'cheapest' | 'fastest' | 'priority' | 'custom'
customComparator?: (a: RankedQuote, b: RankedQuote) => number| Strategy | Primary sort | Tie-breaker |
|------------|----------------------------------|---------------------|
| cheapest | amount ascending | rule priority desc |
| fastest | estimated days ascending | cheapest |
| priority | rule priority desc | cheapest |
| custom | host-supplied comparator | — |
Fallback policy
fallback?: {
maxAttempts?: number; // default 3
skipOnError?: boolean; // skip to next carrier vs retry same; default true
timeoutMs?: number; // per-attempt; default 15_000; 0 disables
isRetryable?: (err: unknown) => boolean; // fail-fast on auth errors, etc.
}purchase() returns a full attempt trail so hosts can persist an audit row:
const { label, attempts } = await router.purchase(shipment, ctx);
// attempts: Array<{ carrierCode, attempt, ok, durationMs, reason? }>Capabilities — what the router auto-filters
Adapters declare which operations they support:
interface CarrierCapabilities {
rateShopping?: boolean;
addressVerification?: boolean;
cod?: boolean;
voidLabel?: boolean;
international?: boolean;
multiPackage?: boolean;
webhooks?: boolean;
serviceCodes?: readonly string[];
}The router skips adapters that can't handle the shipment:
- COD shipment + adapter without
cod→ skipped. - International shipment + adapter without
international→ skipped. - Multi-parcel shipment + adapter without
multiPackage→ skipped.
Writing a real adapter
Extend CarrierAdapter in your own adapter package (e.g. @classytic/carrier-bd):
export class PathaoAdapter implements CarrierAdapter {
readonly code = 'pathao';
readonly capabilities: CarrierCapabilities = {
rateShopping: false, // Pathao gives one rate, not a menu
cod: true,
multiPackage: false,
international: false,
};
constructor(private client: PathaoClient) {}
async quoteShipment(input, ctx) {
const rate = await this.client.calculatePrice({ /* map from input */ });
return [{
carrierCode: this.code,
service: 'next_day',
amount: { amount: rate.price, currency: 'BDT' },
estimatedDays: rate.deliveryDays,
}];
}
async buyLabel(input, ctx) { /* ... */ }
async track(ref, ctx) { /* ... */ }
ingestWebhook(payload, headers) { /* normalize Pathao webhook payload */ }
}Wire it into a router:
const router = new CarrierRouter({
registry: new CarrierRegistry([new PathaoAdapter(pathaoClient)]),
rules: [{ carrier: 'pathao', match: { country: 'BD' } }],
});Framework integration
This package ships zero HTTP surface. Hosts wire it into their framework:
- Arc — construct the router once at startup, inject into your shipping-related resources as a dependency.
- Fastify / Hono / tRPC / Express — same pattern. The router is just a class with async methods.
- Cron / workers — call
router.rateShop()/router.purchase()directly.
See @classytic/arc for the recommended Arc wiring pattern.
What this package is NOT
- Not a persistence layer. Labels, tracking rows, audit entries — your host stores them. The router returns data; it does not write.
- Not a webhook receiver. The host receives the webhook, calls
adapter.ingestWebhook(payload), then decides what to do with the normalized events. - Not coupled to any
@classytic/*sibling. Zero imports fromorder,flow,yard, etc. Integration is at the host layer.
Events (reserved namespace)
Event name constants for use when hosts forward router outcomes to their event bus:
import { CARRIER_EVENTS } from '@classytic/carrier';
CARRIER_EVENTS.LABEL_PURCHASED // 'carrier:label.purchased'
CARRIER_EVENTS.LABEL_VOIDED // 'carrier:label.voided'
CARRIER_EVENTS.RATE_SHOPPED // 'carrier:rate.shopped'
CARRIER_EVENTS.TRACKING_UPDATED // 'carrier:tracking.updated'
CARRIER_EVENTS.PURCHASE_FAILED // 'carrier:purchase.failed'The router itself does NOT publish events — that's the host's call after a successful router operation. This keeps the router pure and avoids a mandatory event-transport dependency.
License
MIT
