@mitumbadotafrica/logistics 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +221 -0
- package/dist/index.d.cts +149 -0
- package/dist/index.d.ts +149 -0
- package/dist/index.js +188 -0
- package/package.json +56 -4
- package/README.md +0 -3
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __reExport = (target, mod, secondTarget) => (__copyProps(target, mod, "default"), secondTarget && __copyProps(secondTarget, mod, "default"));
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/index.ts
|
|
22
|
+
var index_exports = {};
|
|
23
|
+
__export(index_exports, {
|
|
24
|
+
DEFAULT_BASE_URL: () => DEFAULT_BASE_URL,
|
|
25
|
+
HttpClient: () => HttpClient,
|
|
26
|
+
LogisticsError: () => LogisticsError,
|
|
27
|
+
MitumbaLogistics: () => MitumbaLogistics,
|
|
28
|
+
pollParcel: () => pollParcel
|
|
29
|
+
});
|
|
30
|
+
module.exports = __toCommonJS(index_exports);
|
|
31
|
+
|
|
32
|
+
// src/errors.ts
|
|
33
|
+
var LogisticsError = class extends Error {
|
|
34
|
+
constructor(status, body) {
|
|
35
|
+
super(body.message ?? body.error);
|
|
36
|
+
this.name = "LogisticsError";
|
|
37
|
+
this.code = body.error;
|
|
38
|
+
this.status = status;
|
|
39
|
+
this.details = body.details;
|
|
40
|
+
this.reason = body.reason;
|
|
41
|
+
}
|
|
42
|
+
/** True for a temporary backend condition (503), e.g. no carriers configured. */
|
|
43
|
+
get isTemporary() {
|
|
44
|
+
return this.status === 503;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// src/config.ts
|
|
49
|
+
var DEFAULT_BASE_URL = "https://api.mitumba.africa";
|
|
50
|
+
|
|
51
|
+
// src/http.ts
|
|
52
|
+
var RETRYABLE_METHODS = /* @__PURE__ */ new Set(["GET"]);
|
|
53
|
+
var HttpClient = class {
|
|
54
|
+
constructor(config = {}) {
|
|
55
|
+
this.baseUrl = stripTrailingSlashes(config.baseUrl ?? DEFAULT_BASE_URL);
|
|
56
|
+
this.maxRetries = config.maxRetries ?? 3;
|
|
57
|
+
this.fetchImpl = config.fetch ?? globalThis.fetch;
|
|
58
|
+
this.debug = config.debug ?? false;
|
|
59
|
+
this.tokenSource = config.token;
|
|
60
|
+
if (typeof this.fetchImpl !== "function") {
|
|
61
|
+
throw new Error(
|
|
62
|
+
"No fetch implementation available. Provide `fetch` in the config for this runtime."
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async resolveToken() {
|
|
67
|
+
if (typeof this.tokenSource === "function") return this.tokenSource();
|
|
68
|
+
return this.tokenSource;
|
|
69
|
+
}
|
|
70
|
+
async get(path, options) {
|
|
71
|
+
return this.request("GET", path, void 0, options);
|
|
72
|
+
}
|
|
73
|
+
async post(path, body, options) {
|
|
74
|
+
return this.request("POST", path, body, options);
|
|
75
|
+
}
|
|
76
|
+
async request(method, path, body, options) {
|
|
77
|
+
const url = `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
|
|
78
|
+
const token = options?.token ?? await this.resolveToken();
|
|
79
|
+
const headers = new Headers();
|
|
80
|
+
if (body !== void 0) headers.set("Content-Type", "application/json");
|
|
81
|
+
if (token) headers.set("Authorization", `Bearer ${token}`);
|
|
82
|
+
const init = {
|
|
83
|
+
method,
|
|
84
|
+
headers,
|
|
85
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0,
|
|
86
|
+
signal: options?.signal
|
|
87
|
+
};
|
|
88
|
+
const canRetry = RETRYABLE_METHODS.has(method);
|
|
89
|
+
let attempt = 0;
|
|
90
|
+
let response;
|
|
91
|
+
for (; ; ) {
|
|
92
|
+
if (this.debug) console.log(`[mitumba-logistics] ${method} ${url} (attempt ${attempt + 1})`);
|
|
93
|
+
try {
|
|
94
|
+
response = await this.fetchImpl(url, init);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (err instanceof Error && err.name === "AbortError") throw err;
|
|
97
|
+
if (attempt < this.maxRetries) {
|
|
98
|
+
await backoff(++attempt);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
throw new LogisticsError(0, {
|
|
102
|
+
error: "network_error",
|
|
103
|
+
message: err instanceof Error ? err.message : "Network request failed"
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
if (response.status >= 500 && canRetry && attempt < this.maxRetries) {
|
|
107
|
+
await backoff(++attempt);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
let errBody;
|
|
114
|
+
try {
|
|
115
|
+
errBody = await response.json();
|
|
116
|
+
} catch {
|
|
117
|
+
errBody = { error: "unknown_error", message: response.statusText };
|
|
118
|
+
}
|
|
119
|
+
throw new LogisticsError(response.status, errBody);
|
|
120
|
+
}
|
|
121
|
+
if (response.status === 204) return void 0;
|
|
122
|
+
return await response.json();
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
function backoff(attempt) {
|
|
126
|
+
const ms = Math.min(2 ** attempt * 100, 2e3);
|
|
127
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
128
|
+
}
|
|
129
|
+
function stripTrailingSlashes(value) {
|
|
130
|
+
let end = value.length;
|
|
131
|
+
while (end > 0 && value.charCodeAt(end - 1) === 47) end--;
|
|
132
|
+
return value.slice(0, end);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/client.ts
|
|
136
|
+
var MitumbaLogistics = class {
|
|
137
|
+
constructor(config = {}) {
|
|
138
|
+
this.http = new HttpClient(config);
|
|
139
|
+
}
|
|
140
|
+
/** Public market discovery: markets, currencies, fulfilment methods. */
|
|
141
|
+
async getMarkets(options) {
|
|
142
|
+
return this.http.get("/logistics/markets", options);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Route and price a parcel for the chosen method. Returns the chosen (cheapest)
|
|
146
|
+
* option plus alternatives; each option carries its persisted `quote_id` to
|
|
147
|
+
* pass to {@link createParcel}.
|
|
148
|
+
*/
|
|
149
|
+
async quote(input, options) {
|
|
150
|
+
return this.http.post("/logistics/quotes", input, options);
|
|
151
|
+
}
|
|
152
|
+
/** Book the carrier chosen by a prior quote and create the parcel. */
|
|
153
|
+
async createParcel(input, options) {
|
|
154
|
+
return this.http.post("/logistics/parcels", input, options);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Buyer-facing parcel view: the normalized status plus the dual-surface
|
|
158
|
+
* carrier tracking passthrough. The collection code is never returned — only
|
|
159
|
+
* whether one exists.
|
|
160
|
+
*/
|
|
161
|
+
async getParcel(parcelId, options) {
|
|
162
|
+
return this.http.get(`/logistics/parcels/${encodeURIComponent(parcelId)}`, options);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// src/polling.ts
|
|
167
|
+
var import_logistics_contract = require("@mitumbadotafrica/logistics-contract");
|
|
168
|
+
async function pollParcel(client, parcelId, options = {}) {
|
|
169
|
+
const deadlineMs = options.deadlineMs ?? 6e5;
|
|
170
|
+
const intervalMs = options.intervalMs ?? 15e3;
|
|
171
|
+
const jitter = options.jitter ?? 0.2;
|
|
172
|
+
const stopOnAction = options.stopOnActionRequired ?? true;
|
|
173
|
+
const startedAt = Date.now();
|
|
174
|
+
let last = null;
|
|
175
|
+
for (; ; ) {
|
|
176
|
+
if (options.signal?.aborted) return last;
|
|
177
|
+
const reqOptions = options.signal ? { signal: options.signal } : {};
|
|
178
|
+
last = await client.getParcel(parcelId, reqOptions);
|
|
179
|
+
options.onUpdate?.(last);
|
|
180
|
+
if ((0, import_logistics_contract.isTerminalParcelStatus)(last.status)) return last;
|
|
181
|
+
if (stopOnAction && last.status === "action_required") return last;
|
|
182
|
+
if (Date.now() - startedAt >= deadlineMs) {
|
|
183
|
+
options.onTimeout?.(last);
|
|
184
|
+
return last;
|
|
185
|
+
}
|
|
186
|
+
const wait = applyJitter(intervalMs, jitter);
|
|
187
|
+
const remaining = deadlineMs - (Date.now() - startedAt);
|
|
188
|
+
await sleep(Math.min(wait, Math.max(remaining, 0)), options.signal);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function applyJitter(baseMs, jitter) {
|
|
192
|
+
if (jitter <= 0) return baseMs;
|
|
193
|
+
const delta = baseMs * jitter;
|
|
194
|
+
return Math.round(baseMs - delta + Math.random() * 2 * delta);
|
|
195
|
+
}
|
|
196
|
+
function sleep(ms, signal) {
|
|
197
|
+
return new Promise((resolve) => {
|
|
198
|
+
if (ms <= 0) return resolve();
|
|
199
|
+
const timer = setTimeout(() => {
|
|
200
|
+
signal?.removeEventListener("abort", onAbort);
|
|
201
|
+
resolve();
|
|
202
|
+
}, ms);
|
|
203
|
+
const onAbort = () => {
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
resolve();
|
|
206
|
+
};
|
|
207
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/index.ts
|
|
212
|
+
__reExport(index_exports, require("@mitumbadotafrica/logistics-contract"), module.exports);
|
|
213
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
214
|
+
0 && (module.exports = {
|
|
215
|
+
DEFAULT_BASE_URL,
|
|
216
|
+
HttpClient,
|
|
217
|
+
LogisticsError,
|
|
218
|
+
MitumbaLogistics,
|
|
219
|
+
pollParcel,
|
|
220
|
+
...require("@mitumbadotafrica/logistics-contract")
|
|
221
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { MarketsResponse, QuoteRequest, QuoteResponse, CreateParcelResponse, ParcelView, LogisticsErrorBody } from '@mitumbadotafrica/logistics-contract';
|
|
2
|
+
export * from '@mitumbadotafrica/logistics-contract';
|
|
3
|
+
|
|
4
|
+
/** Canonical public API base. Override for staging/self-hosting. */
|
|
5
|
+
declare const DEFAULT_BASE_URL = "https://api.mitumba.africa";
|
|
6
|
+
interface MitumbaLogisticsConfig {
|
|
7
|
+
/** API base URL. Defaults to {@link DEFAULT_BASE_URL}. */
|
|
8
|
+
baseUrl?: string;
|
|
9
|
+
/**
|
|
10
|
+
* A bearer token, or a (possibly async) function returning one. The core
|
|
11
|
+
* client is auth-agnostic: it attaches the token as `Authorization: Bearer
|
|
12
|
+
* <token>`. Token acquisition and refresh are the host application's job.
|
|
13
|
+
*/
|
|
14
|
+
token?: string | (() => string | undefined | Promise<string | undefined>);
|
|
15
|
+
/** Max retries on network errors and 5xx responses. Default 3. */
|
|
16
|
+
maxRetries?: number;
|
|
17
|
+
/** Custom fetch implementation (e.g. for RN or tests). Defaults to global fetch. */
|
|
18
|
+
fetch?: typeof fetch;
|
|
19
|
+
/** Log request/response lines to the console. Default false. */
|
|
20
|
+
debug?: boolean;
|
|
21
|
+
}
|
|
22
|
+
interface RequestOptions {
|
|
23
|
+
/** Abort signal to cancel the in-flight request. */
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
/** Per-request bearer override, sent instead of the client's configured token. */
|
|
26
|
+
token?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Input to book a parcel against a prior quote. The sender is Mitumba's masked
|
|
31
|
+
* identity, supplied by the caller (the orders flow) — never a seller identity.
|
|
32
|
+
* `weight_grams` is the real parcel weight (the carrier books and bills by it).
|
|
33
|
+
*/
|
|
34
|
+
interface CreateParcelInput {
|
|
35
|
+
quote_id: string;
|
|
36
|
+
weight_grams: number;
|
|
37
|
+
sender: ShipmentPartyInput;
|
|
38
|
+
recipient: ShipmentPartyInput;
|
|
39
|
+
}
|
|
40
|
+
interface ShipmentPartyInput {
|
|
41
|
+
name: string;
|
|
42
|
+
phone: string;
|
|
43
|
+
city_id?: string | null;
|
|
44
|
+
node_id?: string;
|
|
45
|
+
address_line?: string;
|
|
46
|
+
point?: {
|
|
47
|
+
latitude: number;
|
|
48
|
+
longitude: number;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The headless, carrier-neutral Mitumba Logistics client. Knows the shape of the
|
|
53
|
+
* HTTP contract (via @mitumbadotafrica/logistics-contract) and nothing about how parcels
|
|
54
|
+
* are booked, routed, or tracked. No carrier implementations, no secrets.
|
|
55
|
+
*
|
|
56
|
+
* The buyer picks a fulfilment METHOD in {@link quote}; the backend chooses the
|
|
57
|
+
* carrier. Carrier identity returned in responses is observable metadata — never
|
|
58
|
+
* something the client switches on to drive the flow.
|
|
59
|
+
*/
|
|
60
|
+
declare class MitumbaLogistics {
|
|
61
|
+
private readonly http;
|
|
62
|
+
constructor(config?: MitumbaLogisticsConfig);
|
|
63
|
+
/** Public market discovery: markets, currencies, fulfilment methods. */
|
|
64
|
+
getMarkets(options?: RequestOptions): Promise<MarketsResponse>;
|
|
65
|
+
/**
|
|
66
|
+
* Route and price a parcel for the chosen method. Returns the chosen (cheapest)
|
|
67
|
+
* option plus alternatives; each option carries its persisted `quote_id` to
|
|
68
|
+
* pass to {@link createParcel}.
|
|
69
|
+
*/
|
|
70
|
+
quote(input: QuoteRequest, options?: RequestOptions): Promise<QuoteResponse>;
|
|
71
|
+
/** Book the carrier chosen by a prior quote and create the parcel. */
|
|
72
|
+
createParcel(input: CreateParcelInput, options?: RequestOptions): Promise<CreateParcelResponse>;
|
|
73
|
+
/**
|
|
74
|
+
* Buyer-facing parcel view: the normalized status plus the dual-surface
|
|
75
|
+
* carrier tracking passthrough. The collection code is never returned — only
|
|
76
|
+
* whether one exists.
|
|
77
|
+
*/
|
|
78
|
+
getParcel(parcelId: string, options?: RequestOptions): Promise<ParcelView>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Minimal, framework-agnostic HTTP client. Token acquisition is the host's job
|
|
83
|
+
* (see {@link MitumbaLogisticsConfig.token}). Retries network errors and 5xx
|
|
84
|
+
* with exponential backoff; GET is idempotent-safe to retry, POST is only
|
|
85
|
+
* retried on network errors (never after a server response).
|
|
86
|
+
*/
|
|
87
|
+
declare class HttpClient {
|
|
88
|
+
private readonly baseUrl;
|
|
89
|
+
private readonly maxRetries;
|
|
90
|
+
private readonly fetchImpl;
|
|
91
|
+
private readonly debug;
|
|
92
|
+
private readonly tokenSource;
|
|
93
|
+
constructor(config?: MitumbaLogisticsConfig);
|
|
94
|
+
private resolveToken;
|
|
95
|
+
get<T>(path: string, options?: RequestOptions): Promise<T>;
|
|
96
|
+
post<T>(path: string, body: unknown, options?: RequestOptions): Promise<T>;
|
|
97
|
+
private request;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Thrown for any non-2xx API response and for network/transport failures.
|
|
102
|
+
* `code` is the machine-readable error code from the API envelope (`{ error }`).
|
|
103
|
+
* `status` is 0 for network-level failures. `reason` carries the backend's
|
|
104
|
+
* sub-reason where present (e.g. `no_route` → `not_serviceable`).
|
|
105
|
+
*/
|
|
106
|
+
declare class LogisticsError extends Error {
|
|
107
|
+
readonly code: string;
|
|
108
|
+
readonly status: number;
|
|
109
|
+
readonly details?: unknown;
|
|
110
|
+
readonly reason?: string;
|
|
111
|
+
constructor(status: number, body: LogisticsErrorBody);
|
|
112
|
+
/** True for a temporary backend condition (503), e.g. no carriers configured. */
|
|
113
|
+
get isTemporary(): boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface PollParcelOptions {
|
|
117
|
+
/** Fired on every successful status read. */
|
|
118
|
+
onUpdate?: (parcel: ParcelView) => void;
|
|
119
|
+
/**
|
|
120
|
+
* Client-side safety deadline (ms). When exceeded, polling stops and
|
|
121
|
+
* `onTimeout` fires. This is "still in transit" — it must NOT be treated as a
|
|
122
|
+
* delivery failure. Default 600000 (10 min); parcels are long-lived, so hosts
|
|
123
|
+
* typically poll a view open on screen rather than to completion.
|
|
124
|
+
*/
|
|
125
|
+
deadlineMs?: number;
|
|
126
|
+
onTimeout?: (last: ParcelView | null) => void;
|
|
127
|
+
/** Abort signal to stop polling. */
|
|
128
|
+
signal?: AbortSignal;
|
|
129
|
+
/** Base interval between reads (ms). Default 15000. */
|
|
130
|
+
intervalMs?: number;
|
|
131
|
+
/** Fractional jitter applied to each interval. Default 0.2. */
|
|
132
|
+
jitter?: number;
|
|
133
|
+
/**
|
|
134
|
+
* Stop as soon as the parcel needs the buyer (status `action_required`), so the
|
|
135
|
+
* host can surface the NextAction. Default true. When false, polling continues
|
|
136
|
+
* through an action_required park (it is non-terminal and resumable).
|
|
137
|
+
*/
|
|
138
|
+
stopOnActionRequired?: boolean;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Poll a parcel's status until it reaches a terminal state (delivered, returned,
|
|
142
|
+
* cancelled, lost), needs buyer action, the deadline passes, or the signal
|
|
143
|
+
* aborts. Keeps at most one request in flight. Critically, an UNKNOWN status is
|
|
144
|
+
* treated as non-terminal and keeps polling — elapsed client time never invents
|
|
145
|
+
* a terminal outcome. Resolves with the last view read.
|
|
146
|
+
*/
|
|
147
|
+
declare function pollParcel(client: MitumbaLogistics, parcelId: string, options?: PollParcelOptions): Promise<ParcelView | null>;
|
|
148
|
+
|
|
149
|
+
export { type CreateParcelInput, DEFAULT_BASE_URL, HttpClient, LogisticsError, MitumbaLogistics, type MitumbaLogisticsConfig, type PollParcelOptions, type RequestOptions, type ShipmentPartyInput, pollParcel };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { MarketsResponse, QuoteRequest, QuoteResponse, CreateParcelResponse, ParcelView, LogisticsErrorBody } from '@mitumbadotafrica/logistics-contract';
|
|
2
|
+
export * from '@mitumbadotafrica/logistics-contract';
|
|
3
|
+
|
|
4
|
+
/** Canonical public API base. Override for staging/self-hosting. */
|
|
5
|
+
declare const DEFAULT_BASE_URL = "https://api.mitumba.africa";
|
|
6
|
+
interface MitumbaLogisticsConfig {
|
|
7
|
+
/** API base URL. Defaults to {@link DEFAULT_BASE_URL}. */
|
|
8
|
+
baseUrl?: string;
|
|
9
|
+
/**
|
|
10
|
+
* A bearer token, or a (possibly async) function returning one. The core
|
|
11
|
+
* client is auth-agnostic: it attaches the token as `Authorization: Bearer
|
|
12
|
+
* <token>`. Token acquisition and refresh are the host application's job.
|
|
13
|
+
*/
|
|
14
|
+
token?: string | (() => string | undefined | Promise<string | undefined>);
|
|
15
|
+
/** Max retries on network errors and 5xx responses. Default 3. */
|
|
16
|
+
maxRetries?: number;
|
|
17
|
+
/** Custom fetch implementation (e.g. for RN or tests). Defaults to global fetch. */
|
|
18
|
+
fetch?: typeof fetch;
|
|
19
|
+
/** Log request/response lines to the console. Default false. */
|
|
20
|
+
debug?: boolean;
|
|
21
|
+
}
|
|
22
|
+
interface RequestOptions {
|
|
23
|
+
/** Abort signal to cancel the in-flight request. */
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
/** Per-request bearer override, sent instead of the client's configured token. */
|
|
26
|
+
token?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Input to book a parcel against a prior quote. The sender is Mitumba's masked
|
|
31
|
+
* identity, supplied by the caller (the orders flow) — never a seller identity.
|
|
32
|
+
* `weight_grams` is the real parcel weight (the carrier books and bills by it).
|
|
33
|
+
*/
|
|
34
|
+
interface CreateParcelInput {
|
|
35
|
+
quote_id: string;
|
|
36
|
+
weight_grams: number;
|
|
37
|
+
sender: ShipmentPartyInput;
|
|
38
|
+
recipient: ShipmentPartyInput;
|
|
39
|
+
}
|
|
40
|
+
interface ShipmentPartyInput {
|
|
41
|
+
name: string;
|
|
42
|
+
phone: string;
|
|
43
|
+
city_id?: string | null;
|
|
44
|
+
node_id?: string;
|
|
45
|
+
address_line?: string;
|
|
46
|
+
point?: {
|
|
47
|
+
latitude: number;
|
|
48
|
+
longitude: number;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The headless, carrier-neutral Mitumba Logistics client. Knows the shape of the
|
|
53
|
+
* HTTP contract (via @mitumbadotafrica/logistics-contract) and nothing about how parcels
|
|
54
|
+
* are booked, routed, or tracked. No carrier implementations, no secrets.
|
|
55
|
+
*
|
|
56
|
+
* The buyer picks a fulfilment METHOD in {@link quote}; the backend chooses the
|
|
57
|
+
* carrier. Carrier identity returned in responses is observable metadata — never
|
|
58
|
+
* something the client switches on to drive the flow.
|
|
59
|
+
*/
|
|
60
|
+
declare class MitumbaLogistics {
|
|
61
|
+
private readonly http;
|
|
62
|
+
constructor(config?: MitumbaLogisticsConfig);
|
|
63
|
+
/** Public market discovery: markets, currencies, fulfilment methods. */
|
|
64
|
+
getMarkets(options?: RequestOptions): Promise<MarketsResponse>;
|
|
65
|
+
/**
|
|
66
|
+
* Route and price a parcel for the chosen method. Returns the chosen (cheapest)
|
|
67
|
+
* option plus alternatives; each option carries its persisted `quote_id` to
|
|
68
|
+
* pass to {@link createParcel}.
|
|
69
|
+
*/
|
|
70
|
+
quote(input: QuoteRequest, options?: RequestOptions): Promise<QuoteResponse>;
|
|
71
|
+
/** Book the carrier chosen by a prior quote and create the parcel. */
|
|
72
|
+
createParcel(input: CreateParcelInput, options?: RequestOptions): Promise<CreateParcelResponse>;
|
|
73
|
+
/**
|
|
74
|
+
* Buyer-facing parcel view: the normalized status plus the dual-surface
|
|
75
|
+
* carrier tracking passthrough. The collection code is never returned — only
|
|
76
|
+
* whether one exists.
|
|
77
|
+
*/
|
|
78
|
+
getParcel(parcelId: string, options?: RequestOptions): Promise<ParcelView>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Minimal, framework-agnostic HTTP client. Token acquisition is the host's job
|
|
83
|
+
* (see {@link MitumbaLogisticsConfig.token}). Retries network errors and 5xx
|
|
84
|
+
* with exponential backoff; GET is idempotent-safe to retry, POST is only
|
|
85
|
+
* retried on network errors (never after a server response).
|
|
86
|
+
*/
|
|
87
|
+
declare class HttpClient {
|
|
88
|
+
private readonly baseUrl;
|
|
89
|
+
private readonly maxRetries;
|
|
90
|
+
private readonly fetchImpl;
|
|
91
|
+
private readonly debug;
|
|
92
|
+
private readonly tokenSource;
|
|
93
|
+
constructor(config?: MitumbaLogisticsConfig);
|
|
94
|
+
private resolveToken;
|
|
95
|
+
get<T>(path: string, options?: RequestOptions): Promise<T>;
|
|
96
|
+
post<T>(path: string, body: unknown, options?: RequestOptions): Promise<T>;
|
|
97
|
+
private request;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Thrown for any non-2xx API response and for network/transport failures.
|
|
102
|
+
* `code` is the machine-readable error code from the API envelope (`{ error }`).
|
|
103
|
+
* `status` is 0 for network-level failures. `reason` carries the backend's
|
|
104
|
+
* sub-reason where present (e.g. `no_route` → `not_serviceable`).
|
|
105
|
+
*/
|
|
106
|
+
declare class LogisticsError extends Error {
|
|
107
|
+
readonly code: string;
|
|
108
|
+
readonly status: number;
|
|
109
|
+
readonly details?: unknown;
|
|
110
|
+
readonly reason?: string;
|
|
111
|
+
constructor(status: number, body: LogisticsErrorBody);
|
|
112
|
+
/** True for a temporary backend condition (503), e.g. no carriers configured. */
|
|
113
|
+
get isTemporary(): boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface PollParcelOptions {
|
|
117
|
+
/** Fired on every successful status read. */
|
|
118
|
+
onUpdate?: (parcel: ParcelView) => void;
|
|
119
|
+
/**
|
|
120
|
+
* Client-side safety deadline (ms). When exceeded, polling stops and
|
|
121
|
+
* `onTimeout` fires. This is "still in transit" — it must NOT be treated as a
|
|
122
|
+
* delivery failure. Default 600000 (10 min); parcels are long-lived, so hosts
|
|
123
|
+
* typically poll a view open on screen rather than to completion.
|
|
124
|
+
*/
|
|
125
|
+
deadlineMs?: number;
|
|
126
|
+
onTimeout?: (last: ParcelView | null) => void;
|
|
127
|
+
/** Abort signal to stop polling. */
|
|
128
|
+
signal?: AbortSignal;
|
|
129
|
+
/** Base interval between reads (ms). Default 15000. */
|
|
130
|
+
intervalMs?: number;
|
|
131
|
+
/** Fractional jitter applied to each interval. Default 0.2. */
|
|
132
|
+
jitter?: number;
|
|
133
|
+
/**
|
|
134
|
+
* Stop as soon as the parcel needs the buyer (status `action_required`), so the
|
|
135
|
+
* host can surface the NextAction. Default true. When false, polling continues
|
|
136
|
+
* through an action_required park (it is non-terminal and resumable).
|
|
137
|
+
*/
|
|
138
|
+
stopOnActionRequired?: boolean;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Poll a parcel's status until it reaches a terminal state (delivered, returned,
|
|
142
|
+
* cancelled, lost), needs buyer action, the deadline passes, or the signal
|
|
143
|
+
* aborts. Keeps at most one request in flight. Critically, an UNKNOWN status is
|
|
144
|
+
* treated as non-terminal and keeps polling — elapsed client time never invents
|
|
145
|
+
* a terminal outcome. Resolves with the last view read.
|
|
146
|
+
*/
|
|
147
|
+
declare function pollParcel(client: MitumbaLogistics, parcelId: string, options?: PollParcelOptions): Promise<ParcelView | null>;
|
|
148
|
+
|
|
149
|
+
export { type CreateParcelInput, DEFAULT_BASE_URL, HttpClient, LogisticsError, MitumbaLogistics, type MitumbaLogisticsConfig, type PollParcelOptions, type RequestOptions, type ShipmentPartyInput, pollParcel };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var LogisticsError = class extends Error {
|
|
3
|
+
constructor(status, body) {
|
|
4
|
+
super(body.message ?? body.error);
|
|
5
|
+
this.name = "LogisticsError";
|
|
6
|
+
this.code = body.error;
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.details = body.details;
|
|
9
|
+
this.reason = body.reason;
|
|
10
|
+
}
|
|
11
|
+
/** True for a temporary backend condition (503), e.g. no carriers configured. */
|
|
12
|
+
get isTemporary() {
|
|
13
|
+
return this.status === 503;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// src/config.ts
|
|
18
|
+
var DEFAULT_BASE_URL = "https://api.mitumba.africa";
|
|
19
|
+
|
|
20
|
+
// src/http.ts
|
|
21
|
+
var RETRYABLE_METHODS = /* @__PURE__ */ new Set(["GET"]);
|
|
22
|
+
var HttpClient = class {
|
|
23
|
+
constructor(config = {}) {
|
|
24
|
+
this.baseUrl = stripTrailingSlashes(config.baseUrl ?? DEFAULT_BASE_URL);
|
|
25
|
+
this.maxRetries = config.maxRetries ?? 3;
|
|
26
|
+
this.fetchImpl = config.fetch ?? globalThis.fetch;
|
|
27
|
+
this.debug = config.debug ?? false;
|
|
28
|
+
this.tokenSource = config.token;
|
|
29
|
+
if (typeof this.fetchImpl !== "function") {
|
|
30
|
+
throw new Error(
|
|
31
|
+
"No fetch implementation available. Provide `fetch` in the config for this runtime."
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async resolveToken() {
|
|
36
|
+
if (typeof this.tokenSource === "function") return this.tokenSource();
|
|
37
|
+
return this.tokenSource;
|
|
38
|
+
}
|
|
39
|
+
async get(path, options) {
|
|
40
|
+
return this.request("GET", path, void 0, options);
|
|
41
|
+
}
|
|
42
|
+
async post(path, body, options) {
|
|
43
|
+
return this.request("POST", path, body, options);
|
|
44
|
+
}
|
|
45
|
+
async request(method, path, body, options) {
|
|
46
|
+
const url = `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
|
|
47
|
+
const token = options?.token ?? await this.resolveToken();
|
|
48
|
+
const headers = new Headers();
|
|
49
|
+
if (body !== void 0) headers.set("Content-Type", "application/json");
|
|
50
|
+
if (token) headers.set("Authorization", `Bearer ${token}`);
|
|
51
|
+
const init = {
|
|
52
|
+
method,
|
|
53
|
+
headers,
|
|
54
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0,
|
|
55
|
+
signal: options?.signal
|
|
56
|
+
};
|
|
57
|
+
const canRetry = RETRYABLE_METHODS.has(method);
|
|
58
|
+
let attempt = 0;
|
|
59
|
+
let response;
|
|
60
|
+
for (; ; ) {
|
|
61
|
+
if (this.debug) console.log(`[mitumba-logistics] ${method} ${url} (attempt ${attempt + 1})`);
|
|
62
|
+
try {
|
|
63
|
+
response = await this.fetchImpl(url, init);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err instanceof Error && err.name === "AbortError") throw err;
|
|
66
|
+
if (attempt < this.maxRetries) {
|
|
67
|
+
await backoff(++attempt);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
throw new LogisticsError(0, {
|
|
71
|
+
error: "network_error",
|
|
72
|
+
message: err instanceof Error ? err.message : "Network request failed"
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (response.status >= 500 && canRetry && attempt < this.maxRetries) {
|
|
76
|
+
await backoff(++attempt);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
let errBody;
|
|
83
|
+
try {
|
|
84
|
+
errBody = await response.json();
|
|
85
|
+
} catch {
|
|
86
|
+
errBody = { error: "unknown_error", message: response.statusText };
|
|
87
|
+
}
|
|
88
|
+
throw new LogisticsError(response.status, errBody);
|
|
89
|
+
}
|
|
90
|
+
if (response.status === 204) return void 0;
|
|
91
|
+
return await response.json();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
function backoff(attempt) {
|
|
95
|
+
const ms = Math.min(2 ** attempt * 100, 2e3);
|
|
96
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
97
|
+
}
|
|
98
|
+
function stripTrailingSlashes(value) {
|
|
99
|
+
let end = value.length;
|
|
100
|
+
while (end > 0 && value.charCodeAt(end - 1) === 47) end--;
|
|
101
|
+
return value.slice(0, end);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/client.ts
|
|
105
|
+
var MitumbaLogistics = class {
|
|
106
|
+
constructor(config = {}) {
|
|
107
|
+
this.http = new HttpClient(config);
|
|
108
|
+
}
|
|
109
|
+
/** Public market discovery: markets, currencies, fulfilment methods. */
|
|
110
|
+
async getMarkets(options) {
|
|
111
|
+
return this.http.get("/logistics/markets", options);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Route and price a parcel for the chosen method. Returns the chosen (cheapest)
|
|
115
|
+
* option plus alternatives; each option carries its persisted `quote_id` to
|
|
116
|
+
* pass to {@link createParcel}.
|
|
117
|
+
*/
|
|
118
|
+
async quote(input, options) {
|
|
119
|
+
return this.http.post("/logistics/quotes", input, options);
|
|
120
|
+
}
|
|
121
|
+
/** Book the carrier chosen by a prior quote and create the parcel. */
|
|
122
|
+
async createParcel(input, options) {
|
|
123
|
+
return this.http.post("/logistics/parcels", input, options);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Buyer-facing parcel view: the normalized status plus the dual-surface
|
|
127
|
+
* carrier tracking passthrough. The collection code is never returned — only
|
|
128
|
+
* whether one exists.
|
|
129
|
+
*/
|
|
130
|
+
async getParcel(parcelId, options) {
|
|
131
|
+
return this.http.get(`/logistics/parcels/${encodeURIComponent(parcelId)}`, options);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// src/polling.ts
|
|
136
|
+
import { isTerminalParcelStatus } from "@mitumbadotafrica/logistics-contract";
|
|
137
|
+
async function pollParcel(client, parcelId, options = {}) {
|
|
138
|
+
const deadlineMs = options.deadlineMs ?? 6e5;
|
|
139
|
+
const intervalMs = options.intervalMs ?? 15e3;
|
|
140
|
+
const jitter = options.jitter ?? 0.2;
|
|
141
|
+
const stopOnAction = options.stopOnActionRequired ?? true;
|
|
142
|
+
const startedAt = Date.now();
|
|
143
|
+
let last = null;
|
|
144
|
+
for (; ; ) {
|
|
145
|
+
if (options.signal?.aborted) return last;
|
|
146
|
+
const reqOptions = options.signal ? { signal: options.signal } : {};
|
|
147
|
+
last = await client.getParcel(parcelId, reqOptions);
|
|
148
|
+
options.onUpdate?.(last);
|
|
149
|
+
if (isTerminalParcelStatus(last.status)) return last;
|
|
150
|
+
if (stopOnAction && last.status === "action_required") return last;
|
|
151
|
+
if (Date.now() - startedAt >= deadlineMs) {
|
|
152
|
+
options.onTimeout?.(last);
|
|
153
|
+
return last;
|
|
154
|
+
}
|
|
155
|
+
const wait = applyJitter(intervalMs, jitter);
|
|
156
|
+
const remaining = deadlineMs - (Date.now() - startedAt);
|
|
157
|
+
await sleep(Math.min(wait, Math.max(remaining, 0)), options.signal);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function applyJitter(baseMs, jitter) {
|
|
161
|
+
if (jitter <= 0) return baseMs;
|
|
162
|
+
const delta = baseMs * jitter;
|
|
163
|
+
return Math.round(baseMs - delta + Math.random() * 2 * delta);
|
|
164
|
+
}
|
|
165
|
+
function sleep(ms, signal) {
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
if (ms <= 0) return resolve();
|
|
168
|
+
const timer = setTimeout(() => {
|
|
169
|
+
signal?.removeEventListener("abort", onAbort);
|
|
170
|
+
resolve();
|
|
171
|
+
}, ms);
|
|
172
|
+
const onAbort = () => {
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
resolve();
|
|
175
|
+
};
|
|
176
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/index.ts
|
|
181
|
+
export * from "@mitumbadotafrica/logistics-contract";
|
|
182
|
+
export {
|
|
183
|
+
DEFAULT_BASE_URL,
|
|
184
|
+
HttpClient,
|
|
185
|
+
LogisticsError,
|
|
186
|
+
MitumbaLogistics,
|
|
187
|
+
pollParcel
|
|
188
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mitumbadotafrica/logistics",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Asset-light carrier-orchestration SDK — carrier-neutral headless client for Kenyan couriers. Zero UI, zero carrier knowledge.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Mitumba-Ltd/mitumba-logistics.git",
|
|
9
|
+
"directory": "packages/core"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/Mitumba-Ltd/mitumba-logistics/tree/main/packages/core#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/Mitumba-Ltd/mitumba-logistics/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"logistics",
|
|
17
|
+
"shipping",
|
|
18
|
+
"courier",
|
|
19
|
+
"kenya",
|
|
20
|
+
"africa",
|
|
21
|
+
"pickup-mtaani",
|
|
22
|
+
"sdk"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "./dist/index.cjs",
|
|
26
|
+
"module": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js",
|
|
32
|
+
"require": "./dist/index.cjs"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist"
|
|
37
|
+
],
|
|
38
|
+
"sideEffects": false,
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"provenance": true
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
45
|
+
"dev": "tsup src/index.ts --format esm,cjs --dts --watch",
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"lint": "eslint src",
|
|
49
|
+
"clean": "rimraf dist .turbo"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@mitumbadotafrica/logistics-contract": "0.1.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"rimraf": "^6.0.1",
|
|
56
|
+
"vitest": "^2.1.1"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/README.md
DELETED