smklog 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.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +162 -2
  3. package/index.d.ts +277 -0
  4. package/index.js +283 -0
  5. package/package.json +49 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dmitrii T
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,163 @@
1
- # Temporary Holding Version
1
+ # smklog
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Official JavaScript / TypeScript client for the [SMKlog Quote API](https://smklog.com/developers/):
4
+ live USPS, UPS, FedEx and DHL Express parcel rates from a US origin, a checkout handed to a
5
+ human, and where that checkout stands. No API key, no account.
6
+
7
+ ESM, Node 18 or newer (global `fetch`), no dependencies, no build step: one `index.js` and
8
+ its `index.d.ts`.
9
+
10
+ ## Install
11
+
12
+ ```
13
+ npm install smklog
14
+ ```
15
+
16
+ ## Quickstart
17
+
18
+ ```js
19
+ import { SmklogClient } from 'smklog';
20
+
21
+ const client = new SmklogClient();
22
+ const product = 'Yamaha FG800 acoustic guitar in a hard case';
23
+ const quote = await client.getParcelQuote({ product, from_postal_code: '07102', to_postal_code: '30303' });
24
+ const best = quote.isPriced() ? quote.cheapest() : null;
25
+ if (!best) throw new Error(`${quote.mode}: ${quote.notice}`); // not_a_parcel, review, ...
26
+ console.log(best.carrier, best.service, best.amount); // USPS Ground Advantage 38.42
27
+ // quote_id is good for 15 minutes; a session opened with it reuses these rates, so the pair
28
+ // costs one carrier call. It is null when the handle was not stored: then price live.
29
+ const session = await client.createPaymentSession(quote.quote_id
30
+ ? { quote_id: quote.quote_id, service: best.service }
31
+ : { product, from_zip: '07102', to_zip: '30303', service: best.service });
32
+ console.log(session.handoff_url, session.quote_reused); // the human pays here; true
33
+ if (session.session_id) {
34
+ const status = await client.getCheckoutStatus(session.session_id);
35
+ console.log(status.status); // awaiting_checkout ... label_ready
36
+ }
37
+ ```
38
+
39
+ The same snippet compiles as TypeScript under `--strict`: `cheapest()` is `Rate | null`,
40
+ `quote_id` is `string | null | undefined` and `session_id` is `string | null`, so all three are
41
+ checked before use.
42
+
43
+ Every `getParcelQuote` call spends a live carrier rating that costs SMKlog money: one call per
44
+ shipment, cache on your side, never poll. Pass the answer's `quote_id` to `createPaymentSession`
45
+ and the pair costs one carrier call; a session opened from the shipment fields alone prices the
46
+ shipment again. Quote once, let the human settle on the parcel, then open one session.
47
+
48
+ ## Operations
49
+
50
+ | Method | Endpoint | Returns |
51
+ | --- | --- | --- |
52
+ | `getParcelQuote(request)` | `POST /quote` | `QuoteResponse` |
53
+ | `createPaymentSession(request)` | `POST /agent/checkout-session` | `CheckoutSession` |
54
+ | `getCheckoutStatus(sessionId)` | `GET /agent/checkout-session/{session_id}` | `CheckoutStatus` |
55
+ | `status()` | `GET /status` | `Liveness` |
56
+
57
+ Field names are `snake_case` exactly as the API sends and receives them; the client renames
58
+ nothing and keeps fields it does not know about.
59
+
60
+ `getParcelQuote` resolves to a `QuoteResponse` object: the wire fields are its own properties
61
+ (so `JSON.stringify(quote)` is the raw answer) and two helpers live on its prototype:
62
+
63
+ - `quote.cheapest()`: the rate with the lowest `amount`, or `null` when there are none.
64
+ - `quote.isPriced()`: `true` when `mode` is `parcel_label_ready` and at least one rate came back.
65
+
66
+ `quote.rates` stays in the API's order (best value first).
67
+
68
+ `createPaymentSession` takes one of two forms:
69
+
70
+ - `{ quote_id, service? }` (preferred): `quote_id` is the handle a `getParcelQuote` answer carries,
71
+ valid 15 minutes. The session reuses that quote's rates, no second carrier call is spent, and
72
+ the answer says so with `quote_reused: true`. The shipment fields are ignored while the handle
73
+ is valid.
74
+ - `{ product, from_zip, to_zip, to_country?, quantity?, service? }`: the shipment itself, priced
75
+ live when the checkout opens. `quantity` is sent as `1` when omitted.
76
+
77
+ An expired `quote_id` makes the API price the shipment fields live instead, so pass them
78
+ alongside an old id when you have them. `quote.quote_id` is `null` when nothing was priced or the
79
+ handle could not be stored, and absent on answers without rates; the client passes a `quote_id`
80
+ through as given and never invents one.
81
+
82
+ ## Options
83
+
84
+ ```js
85
+ new SmklogClient({
86
+ baseUrl: 'https://quote-api.smklog.com', // default
87
+ timeout: 30_000, // ms per attempt; a quote can take 10-20 s
88
+ token: undefined, // OAuth bearer for a dedicated rate-limit bucket
89
+ userAgent: 'my-app/2.0', // appended after smklog-sdk-javascript/0.1.0
90
+ });
91
+ ```
92
+
93
+ ## Errors
94
+
95
+ Every failure is a `SmklogError` with `status` (HTTP status, `0` when no response arrived),
96
+ `error` (the API's stable code), `message`, `missing` (field names, may be empty) and `body`
97
+ (the parsed `Problem`, or the raw text when the body was not JSON). When the `Problem` carries
98
+ no `message`, the SDK's message is the code plus the missing fields
99
+ (`missing_required_fields: missing to_postal_code`). Subclasses by code:
100
+
101
+ | `error` code | Class |
102
+ | --- | --- |
103
+ | `rate_limited`, `slow_down`, and any other 429 (an edge rate limit without a `Problem` body reads `rate_limited`) | `RateLimitedError` (`retryAfter` seconds when the API sent the header) |
104
+ | `product_not_recognized`, `product_url_not_detected`, `missing_required_fields`, `invalid_request`, `invalid_json` | `InvalidRequestError` |
105
+ | `session_not_found` | `NotFoundError` |
106
+ | `session_failed` | `SessionFailedError` |
107
+ | anything else | `SmklogError` |
108
+
109
+ Network failures and timeouts raise `SmklogError` with `status: 0`, `error: 'network_error'`
110
+ and the native error as `cause`.
111
+
112
+ Local validation runs before any request and raises `InvalidRequestError` with `status: 0`:
113
+ a 5-digit US origin ZIP (and destination ZIP on US lanes), `to_country_code` in `US, CA, GB,
114
+ DE, AU`, `quantity` an integer of 1 or more, and the four measurements (`length_cm`,
115
+ `width_cm`, `height_cm`, `weight_kg`) all or none. `quantity` is sent as `1` when omitted.
116
+
117
+ ```js
118
+ import { SmklogError, RateLimitedError } from 'smklog';
119
+
120
+ try {
121
+ await client.getParcelQuote(request);
122
+ } catch (error) {
123
+ if (error instanceof RateLimitedError) scheduleForLater(error.retryAfter);
124
+ else if (error instanceof SmklogError) console.error(error.status, error.error, error.message);
125
+ else throw error;
126
+ }
127
+ ```
128
+
129
+ ### Retries and rate limits
130
+
131
+ - **A 429 is never retried.** The buckets are hourly (about 80 quotes, 60 status reads, 20
132
+ token requests per client); a retry loop only burns the bucket faster. The caller decides.
133
+ - `getCheckoutStatus` and `status` are retried at most twice, with a short backoff, on
134
+ network failures and 5xx answers.
135
+ - `getParcelQuote` and `createPaymentSession` are never retried automatically: a quote spends
136
+ a live carrier rating, a session opens a checkout.
137
+ - Each attempt is bounded by `timeout` through an `AbortController`.
138
+
139
+ ## TypeScript
140
+
141
+ `index.d.ts` declares the client, its options, every model from the published JSON Schemas
142
+ (`QuoteRequest`, `QuoteResponse`, `Rate`, `Package`, `CheckoutSessionRequest`,
143
+ `CheckoutSession`, `CheckoutStatus`, `Liveness`, `Problem`) and the error classes, `cause`
144
+ included, so the declarations hold on any `target` and `lib`.
145
+
146
+ ## Tests
147
+
148
+ ```
149
+ node --test
150
+ ```
151
+
152
+ The tests replay the example payloads vendored in `../testdata/` from a local HTTP server and
153
+ never reach the network. `SMKLOG_LIVE=1 node --test` adds one live call to `status()`
154
+ against production; nothing in the suite ever calls `POST /quote` for real.
155
+
156
+ ## Links
157
+
158
+ - Developer portal: https://smklog.com/developers/
159
+ - OpenAPI 3.1 contract: https://quote-api.smklog.com/openapi.json
160
+ - Rate limits: https://smklog.com/developers/rate-limits
161
+ - Status: https://smklog.com/developers/status
162
+
163
+ MIT license; see [LICENSE](LICENSE).
package/index.d.ts ADDED
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Type declarations for the SMKlog Quote API client (package "smklog").
3
+ *
4
+ * Models mirror testdata/schemas/*.schema.json field for field; property
5
+ * names are snake_case exactly as they travel on the wire.
6
+ */
7
+
8
+ /** SDK version, also the version in the User-Agent header. */
9
+ export const VERSION: string;
10
+
11
+ export interface SmklogClientOptions {
12
+ /** API origin. Default "https://quote-api.smklog.com". */
13
+ baseUrl?: string;
14
+ /** Per-attempt timeout in milliseconds. Default 30000: a quote can take 10-20 s while carriers answer. */
15
+ timeout?: number;
16
+ /** Optional OAuth bearer for a dedicated rate-limit bucket; sent as "Authorization: Bearer ..." only when set. */
17
+ token?: string;
18
+ /** Appended after the SDK's own product token: "smklog-sdk-javascript/0.1.0 <userAgent>". */
19
+ userAgent?: string;
20
+ }
21
+
22
+ export type CountryCode = 'US' | 'CA' | 'GB' | 'DE' | 'AU';
23
+ export type Carrier = 'USPS' | 'UPS' | 'FedEx' | 'DHL';
24
+ export type PackageConfidence = 'low' | 'medium' | 'high' | 'manual';
25
+ export type QuoteMode =
26
+ | 'parcel_label_ready'
27
+ | 'not_a_parcel'
28
+ | 'multiple_boxes'
29
+ | 'international_unsupported_online'
30
+ | 'parcel_restricted'
31
+ | 'review'
32
+ | 'parcel_rates_unavailable';
33
+ export type CheckoutStage =
34
+ | 'awaiting_checkout'
35
+ | 'checkout_started'
36
+ | 'paid'
37
+ | 'label_ready'
38
+ | 'delivered'
39
+ | 'refunded';
40
+
41
+ /** One shipment to price (schema QuoteRequest). */
42
+ export interface QuoteRequest {
43
+ /** Plain-words item description. Required unless product_url is given. */
44
+ product?: string;
45
+ /** Store product page URL; the item is identified from the page. Alternative to product. */
46
+ product_url?: string;
47
+ /** How many of the item ship together. The SDK sends 1 when omitted. */
48
+ quantity?: number;
49
+ /** 5-digit US ZIP; the origin is always in the United States. */
50
+ from_postal_code: string;
51
+ /** 5-digit US ZIP for US lanes, otherwise the destination country's own postal code. */
52
+ to_postal_code: string;
53
+ /** Destination country, default US. */
54
+ to_country_code?: CountryCode;
55
+ /** Packed box length, cm. Send all four measurements or none. */
56
+ length_cm?: number;
57
+ /** Packed box width, cm. */
58
+ width_cm?: number;
59
+ /** Packed box height, cm. */
60
+ height_cm?: number;
61
+ /** Packed weight, kg. */
62
+ weight_kg?: number;
63
+ /** Set to "manual" when supplying exact dimensions. */
64
+ package_source?: 'manual';
65
+ }
66
+
67
+ /** The packed box used for pricing (schema Package). */
68
+ export interface Package {
69
+ quantity?: number;
70
+ length_cm?: number;
71
+ width_cm?: number;
72
+ height_cm?: number;
73
+ weight_kg?: number;
74
+ description?: string;
75
+ /** "manual" when the caller supplied the measurements. */
76
+ confidence?: PackageConfidence;
77
+ assumptions?: string[];
78
+ }
79
+
80
+ /** One purchasable service (schema Rate). */
81
+ export interface Rate {
82
+ carrier: Carrier;
83
+ /** Service display name, e.g. "Ground Advantage". */
84
+ service: string;
85
+ /** Carrier service code, stable across calls. */
86
+ service_code?: string;
87
+ /** Checkout total, USD, SMKlog fee inside. */
88
+ amount: number;
89
+ /** Carrier retail counter price, USD; 0 when not published. */
90
+ retail_amount?: number;
91
+ delivery_days?: number | null;
92
+ /** e.g. "2-5 business days". */
93
+ delivery_window?: string | null;
94
+ /** ISO date the carrier estimates. */
95
+ delivery_estimated_date?: string | null;
96
+ currency: 'USD';
97
+ }
98
+
99
+ /** The wire fields of a quote answer (schema QuoteResponse, additionalProperties true). */
100
+ export interface QuoteResponseData {
101
+ /** Only parcel_label_ready carries rates. */
102
+ mode: QuoteMode;
103
+ identified_product_title?: string;
104
+ /** Handle for createPaymentSession, valid 15 minutes; null when nothing was priced or the handle could not be stored, absent on answers without rates. */
105
+ quote_id?: string | null;
106
+ package?: Package;
107
+ /** Every box the estimate produced; one entry when the shipment is one parcel. */
108
+ packages?: Package[];
109
+ /** Up to five purchasable services, best value first. */
110
+ rates: Rate[];
111
+ dangerous_goods?: Record<string, unknown> | null;
112
+ /** Product pages the estimate read, when a search informed it. */
113
+ sources?: Array<{ title?: string; url?: string }>;
114
+ /** Human-readable reason when mode is not parcel_label_ready. */
115
+ notice?: string | null;
116
+ source?: { package?: string; rates?: string; [key: string]: unknown };
117
+ [key: string]: unknown;
118
+ }
119
+
120
+ /**
121
+ * What getParcelQuote resolves to: the wire fields as own properties (so
122
+ * JSON.stringify gives the raw answer) plus two helpers on the prototype.
123
+ */
124
+ export interface QuoteResponse extends QuoteResponseData {}
125
+ export class QuoteResponse {
126
+ constructor(body: QuoteResponseData);
127
+ /** The rate with the lowest amount, or null when there are no rates. */
128
+ cheapest(): Rate | null;
129
+ /** True when mode is parcel_label_ready and at least one rate came back. */
130
+ isPriced(): boolean;
131
+ }
132
+
133
+ /** The shipment to open a checkout for (schema CheckoutSessionRequest). */
134
+ export interface CheckoutSessionRequest {
135
+ /** The quote_id a getParcelQuote answer carries, valid 15 minutes: the session reuses that quote's rates and the shipment fields are ignored. An expired handle makes the API price the shipment fields live, so send them alongside an old id when you have them. */
136
+ quote_id?: string;
137
+ /** Required unless quote_id is given. */
138
+ product?: string;
139
+ /** 5-digit US ZIP. Required unless quote_id is given. */
140
+ from_zip?: string;
141
+ /** 5-digit US ZIP, or the destination country's own postal code. Required unless quote_id is given. */
142
+ to_zip?: string;
143
+ to_country?: CountryCode;
144
+ /** The SDK sends 1 when omitted and no quote_id is given. */
145
+ quantity?: number;
146
+ /** Service to anchor the amount to, matched as a case-insensitive fragment; cheapest when omitted. */
147
+ service?: string;
148
+ }
149
+
150
+ /** A payment session, not a charge (schema CheckoutSession). */
151
+ export interface CheckoutSession {
152
+ intent: 'session';
153
+ /** Handle for getCheckoutStatus, good for 30 days; null only when the session record could not be stored. */
154
+ session_id?: string | null;
155
+ method?: string[];
156
+ /** Live-quoted checkout total for the anchored service, USD. */
157
+ amount: number;
158
+ currency: 'USD';
159
+ service?: string;
160
+ /** Opens the SMKlog checkout prefilled with this shipment. */
161
+ handoff_url: string;
162
+ identified_product_title?: string;
163
+ /** True when the amount came from a quote_id, so no carrier call was spent. */
164
+ quote_reused?: boolean;
165
+ note?: string;
166
+ }
167
+
168
+ /** The checkout a human started, nested in CheckoutStatus. */
169
+ export interface CheckoutStatusCheckout {
170
+ /** SMKlog order id, the reference for support. */
171
+ order_id?: string;
172
+ started_at?: string;
173
+ paid?: boolean;
174
+ label_ready?: boolean;
175
+ refunded?: boolean;
176
+ carrier?: string | null;
177
+ /** The service the human actually chose. */
178
+ service?: string | null;
179
+ /** Present from label_ready on. */
180
+ tracking_number?: string | null;
181
+ tracking_status?: string | null;
182
+ /** ISO date when the carrier gives one. */
183
+ estimated_delivery?: string | null;
184
+ delivered_at?: string | null;
185
+ }
186
+
187
+ /** Where a checkout stands (schema CheckoutStatus). Never carries names, addresses or emails. */
188
+ export interface CheckoutStatus {
189
+ session_id: string;
190
+ status: CheckoutStage;
191
+ created_at?: string;
192
+ expires_at?: string;
193
+ amount?: number;
194
+ currency?: 'USD';
195
+ service?: string;
196
+ quote_reused?: boolean;
197
+ /** Null while awaiting_checkout. */
198
+ checkout?: CheckoutStatusCheckout | null;
199
+ /** What, if anything, the agent should do now. */
200
+ next?: string;
201
+ }
202
+
203
+ /** The liveness answer (schema Liveness). */
204
+ export interface Liveness {
205
+ ok: true;
206
+ service: 'smklog-quote-api';
207
+ /** Product version shared by the OpenAPI, the MCP server and the agent card. */
208
+ version: string;
209
+ /** Server time, ISO 8601. */
210
+ time: string;
211
+ last_estimate_race?: Record<string, unknown> | null;
212
+ }
213
+
214
+ /** Every error answer on the host (schema Problem). */
215
+ export interface Problem {
216
+ /** Stable code, e.g. rate_limited, missing_required_fields, session_not_found. */
217
+ error: string;
218
+ message?: string;
219
+ /** Field names still needed. */
220
+ missing?: string[];
221
+ }
222
+
223
+ export interface SmklogErrorOptions {
224
+ status?: number;
225
+ error?: string;
226
+ missing?: string[];
227
+ body?: unknown;
228
+ cause?: unknown;
229
+ }
230
+
231
+ /** Base error: every failure the client raises, local or from the API. */
232
+ export class SmklogError extends Error {
233
+ /** HTTP status; 0 when no response arrived (network failure, timeout) or the request was rejected locally. */
234
+ readonly status: number;
235
+ /** Stable code: the API's own (rate_limited, session_not_found, ...), network_error, http_error, invalid_response, or invalid_request / missing_required_fields for local validation. A 429 without a Problem body reads rate_limited. */
236
+ readonly error: string;
237
+ /** Field names still needed; empty unless the code says otherwise. */
238
+ readonly missing: string[];
239
+ /** The parsed Problem, the raw body text when it was not JSON, or null. */
240
+ readonly body: unknown;
241
+ /** The native error behind a network_error, else undefined. Declared here so it exists whatever lib the consumer targets. */
242
+ readonly cause?: unknown;
243
+ constructor(message: string, options?: SmklogErrorOptions);
244
+ }
245
+
246
+ /** Every 429: rate_limited, slow_down, or an edge rate limit without a Problem body. The SDK never retries it; the caller decides. */
247
+ export class RateLimitedError extends SmklogError {
248
+ /** Seconds from the Retry-After header when the API sent one, else null. */
249
+ readonly retryAfter: number | null;
250
+ constructor(message: string, options?: SmklogErrorOptions & { retryAfter?: number | null });
251
+ }
252
+
253
+ /** product_not_recognized, product_url_not_detected, missing_required_fields, invalid_request, invalid_json, and local validation failures (status 0). */
254
+ export class InvalidRequestError extends SmklogError {}
255
+
256
+ /** session_not_found. */
257
+ export class NotFoundError extends SmklogError {}
258
+
259
+ /** session_failed: the API could not open a checkout for this shipment. */
260
+ export class SessionFailedError extends SmklogError {}
261
+
262
+ export class SmklogClient {
263
+ readonly baseUrl: string;
264
+ readonly timeout: number;
265
+ readonly token: string | null;
266
+ /** The full User-Agent sent, SDK product token first. */
267
+ readonly userAgent: string;
268
+ constructor(options?: SmklogClientOptions);
269
+ /** POST /quote. Spends a live carrier rating; never retried. */
270
+ getParcelQuote(request: QuoteRequest): Promise<QuoteResponse>;
271
+ /** POST /agent/checkout-session. Opens a checkout for a human; nothing is charged. Never retried. */
272
+ createPaymentSession(request: CheckoutSessionRequest): Promise<CheckoutSession>;
273
+ /** GET /agent/checkout-session/{session_id}. Retried up to twice on network failure or 5xx. */
274
+ getCheckoutStatus(sessionId: string): Promise<CheckoutStatus>;
275
+ /** GET /status. Retried up to twice on network failure or 5xx. */
276
+ status(): Promise<Liveness>;
277
+ }
package/index.js ADDED
@@ -0,0 +1,283 @@
1
+ // SMKlog Quote API client. Thin by design: four operations, validation that
2
+ // saves a wasted request, and errors typed by the API's own error codes.
3
+ // Wire field names stay snake_case exactly as the API sends them.
4
+
5
+ export const VERSION = '0.1.0';
6
+
7
+ const DEFAULT_BASE_URL = 'https://quote-api.smklog.com';
8
+ // Carriers answer in 10-20 s on a cold lane, so the default is generous.
9
+ const DEFAULT_TIMEOUT_MS = 30_000;
10
+ // Retry only the idempotent GETs, and only a little: a status read that
11
+ // fails three times in a row is an outage the caller should see.
12
+ const MAX_GET_ATTEMPTS = 3;
13
+ const BACKOFF_MS = 200;
14
+
15
+ const COUNTRIES = new Set(['US', 'CA', 'GB', 'DE', 'AU']);
16
+ const US_ZIP = /^[0-9]{5}$/;
17
+ const MEASUREMENTS = ['length_cm', 'width_cm', 'height_cm', 'weight_kg'];
18
+
19
+ // --- Errors ---------------------------------------------------------------
20
+
21
+ export class SmklogError extends Error {
22
+ constructor(message, { status = 0, error = 'network_error', missing = [], body = null, cause } = {}) {
23
+ super(message, cause === undefined ? undefined : { cause });
24
+ this.name = this.constructor.name;
25
+ this.status = status;
26
+ this.error = error;
27
+ this.missing = Array.isArray(missing) ? missing : [];
28
+ this.body = body;
29
+ }
30
+ }
31
+
32
+ export class RateLimitedError extends SmklogError {
33
+ constructor(message, options = {}) {
34
+ super(message, options);
35
+ // The API does not promise a Retry-After header; expose it when present so
36
+ // the caller (never the SDK) can decide when to come back.
37
+ this.retryAfter = options.retryAfter ?? null;
38
+ }
39
+ }
40
+
41
+ export class InvalidRequestError extends SmklogError {}
42
+
43
+ export class NotFoundError extends SmklogError {}
44
+
45
+ export class SessionFailedError extends SmklogError {}
46
+
47
+ const ERROR_CLASS_BY_CODE = {
48
+ rate_limited: RateLimitedError,
49
+ slow_down: RateLimitedError,
50
+ product_not_recognized: InvalidRequestError,
51
+ product_url_not_detected: InvalidRequestError,
52
+ missing_required_fields: InvalidRequestError,
53
+ invalid_request: InvalidRequestError,
54
+ invalid_json: InvalidRequestError,
55
+ session_not_found: NotFoundError,
56
+ session_failed: SessionFailedError,
57
+ };
58
+
59
+ function errorFromResponse(response, problem, rawText) {
60
+ const isProblem = problem && typeof problem === 'object' && typeof problem.error === 'string';
61
+ // An edge rate limit answers 429 with an HTML or empty body instead of the
62
+ // worker's Problem; it still must not be retried, so the status alone
63
+ // decides the class and the code when the body names none.
64
+ const rateLimited = response.status === 429;
65
+ const error = isProblem ? problem.error : rateLimited ? 'rate_limited' : 'http_error';
66
+ const missing = isProblem && Array.isArray(problem.missing) ? problem.missing : [];
67
+ // A Problem may carry no sentence (missing_required_fields lists fields
68
+ // instead), and a log line of error.message should still say what went wrong.
69
+ const message = (isProblem && problem.message)
70
+ || (isProblem ? `${error}${missing.length ? ': missing ' + missing.join(', ') : ''}` : `HTTP ${response.status} from ${response.url}`);
71
+ const ErrorClass = rateLimited ? RateLimitedError : ERROR_CLASS_BY_CODE[error] || SmklogError;
72
+ const retryAfterHeader = Number(response.headers.get('retry-after'));
73
+ return new ErrorClass(message, {
74
+ status: response.status,
75
+ error,
76
+ missing,
77
+ body: isProblem ? problem : rawText,
78
+ retryAfter: Number.isFinite(retryAfterHeader) && retryAfterHeader > 0 ? retryAfterHeader : null,
79
+ });
80
+ }
81
+
82
+ // Local validation failures reuse the API's own codes so one catch handles
83
+ // both; status 0 tells them apart from a refusal the server actually sent.
84
+ function invalid(message, missing = []) {
85
+ return new InvalidRequestError(message, {
86
+ status: 0,
87
+ error: missing.length ? 'missing_required_fields' : 'invalid_request',
88
+ missing,
89
+ });
90
+ }
91
+
92
+ // --- Models ---------------------------------------------------------------
93
+
94
+ // The quote body plus two conveniences. Own properties are exactly the wire
95
+ // fields (unknown ones included), so JSON.stringify(quote) is the raw answer.
96
+ export class QuoteResponse {
97
+ constructor(body) {
98
+ Object.assign(this, body);
99
+ }
100
+
101
+ isPriced() {
102
+ return this.mode === 'parcel_label_ready' && Array.isArray(this.rates) && this.rates.length > 0;
103
+ }
104
+
105
+ cheapest() {
106
+ let best = null;
107
+ for (const rate of Array.isArray(this.rates) ? this.rates : []) {
108
+ if (typeof rate.amount === 'number' && (best === null || rate.amount < best.amount)) best = rate;
109
+ }
110
+ return best;
111
+ }
112
+ }
113
+
114
+ // --- Validation -----------------------------------------------------------
115
+
116
+ function requireObject(request, name) {
117
+ if (!request || typeof request !== 'object' || Array.isArray(request)) {
118
+ throw invalid(`${name} must be an object`);
119
+ }
120
+ }
121
+
122
+ // The origin is always a US ZIP; the destination is one only for US lanes.
123
+ function validateRoute(request, fromKey, toKey, countryKey) {
124
+ const missing = [fromKey, toKey].filter((key) => request[key] === undefined || request[key] === null || request[key] === '');
125
+ if (missing.length) throw invalid(`${missing.join(', ')} required`, missing);
126
+
127
+ const country = request[countryKey];
128
+ if (country !== undefined && !COUNTRIES.has(country)) {
129
+ throw invalid(`${countryKey} must be one of ${[...COUNTRIES].join(', ')} (got ${JSON.stringify(country)})`);
130
+ }
131
+ if (!US_ZIP.test(String(request[fromKey]))) {
132
+ throw invalid(`${fromKey} must be a 5-digit US ZIP (got ${JSON.stringify(request[fromKey])})`);
133
+ }
134
+ const to = request[toKey];
135
+ if (country === undefined || country === 'US') {
136
+ if (!US_ZIP.test(String(to))) throw invalid(`${toKey} must be a 5-digit US ZIP (got ${JSON.stringify(to)})`);
137
+ } else if (typeof to !== 'string' || to.length > 12) {
138
+ throw invalid(`${toKey} must be the destination postal code, up to 12 characters (got ${JSON.stringify(to)})`);
139
+ }
140
+
141
+ const quantity = request.quantity;
142
+ if (quantity !== undefined && (!Number.isInteger(quantity) || quantity < 1)) {
143
+ throw invalid(`quantity must be an integer of 1 or more (got ${JSON.stringify(quantity)})`);
144
+ }
145
+ }
146
+
147
+ function validateQuoteRequest(request) {
148
+ requireObject(request, 'quote request');
149
+ if (!request.product && !request.product_url) throw invalid('product or product_url required', ['product']);
150
+ validateRoute(request, 'from_postal_code', 'to_postal_code', 'to_country_code');
151
+
152
+ // An exact box needs all four numbers; a partial set would silently fall
153
+ // back to the estimate and price a different parcel than the caller meant.
154
+ const given = MEASUREMENTS.filter((key) => request[key] !== undefined && request[key] !== null);
155
+ if (given.length !== 0 && given.length !== MEASUREMENTS.length) {
156
+ throw invalid(`send all of ${MEASUREMENTS.join(', ')} or none of them (got ${given.join(', ')})`);
157
+ }
158
+ for (const key of given) {
159
+ if (typeof request[key] !== 'number' || !Number.isFinite(request[key]) || request[key] <= 0) {
160
+ throw invalid(`${key} must be a positive number (got ${JSON.stringify(request[key])})`);
161
+ }
162
+ }
163
+ }
164
+
165
+ function validateCheckoutSessionRequest(request) {
166
+ requireObject(request, 'checkout session request');
167
+ if (request.quote_id !== undefined && request.quote_id !== null) {
168
+ if (typeof request.quote_id !== 'string' || request.quote_id === '') throw invalid('quote_id must be a non-empty string');
169
+ return;
170
+ }
171
+ if (!request.product) throw invalid('product required when quote_id is not given', ['product']);
172
+ validateRoute(request, 'from_zip', 'to_zip', 'to_country');
173
+ }
174
+
175
+ // --- Client ---------------------------------------------------------------
176
+
177
+ export class SmklogClient {
178
+ constructor({ baseUrl = DEFAULT_BASE_URL, timeout = DEFAULT_TIMEOUT_MS, token, userAgent } = {}) {
179
+ this.baseUrl = String(baseUrl).replace(/\/+$/, '');
180
+ this.timeout = timeout;
181
+ this.token = token || null;
182
+ this.userAgent = userAgent ? `smklog-sdk-javascript/${VERSION} ${userAgent}` : `smklog-sdk-javascript/${VERSION}`;
183
+ }
184
+
185
+ async getParcelQuote(request) {
186
+ validateQuoteRequest(request);
187
+ // quantity is required on the wire with a documented default of 1.
188
+ const body = await this.#request('POST', '/quote', { quantity: 1, ...request });
189
+ return new QuoteResponse(body);
190
+ }
191
+
192
+ async createPaymentSession(request) {
193
+ validateCheckoutSessionRequest(request);
194
+ // A quote_id names the shipment, so no quantity default is added; shipment
195
+ // fields sent alongside it ride through untouched for the case where the
196
+ // handle has expired and the API prices them live instead.
197
+ const body = request.quote_id ? { ...request } : { quantity: 1, ...request };
198
+ return this.#request('POST', '/agent/checkout-session', body);
199
+ }
200
+
201
+ async getCheckoutStatus(sessionId) {
202
+ if (typeof sessionId !== 'string' || sessionId === '') throw invalid('session_id must be a non-empty string', ['session_id']);
203
+ return this.#request('GET', `/agent/checkout-session/${encodeURIComponent(sessionId)}`);
204
+ }
205
+
206
+ async status() {
207
+ return this.#request('GET', '/status');
208
+ }
209
+
210
+ async #request(method, path, body) {
211
+ const headers = { Accept: 'application/json', 'User-Agent': this.userAgent };
212
+ if (this.token) headers.Authorization = `Bearer ${this.token}`;
213
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
214
+ const init = { method, headers, body: body === undefined ? undefined : JSON.stringify(body) };
215
+
216
+ // A POST is never retried: /quote spends a live carrier rating and
217
+ // /agent/checkout-session opens a session, so a repeat is not free.
218
+ const attempts = method === 'GET' ? MAX_GET_ATTEMPTS : 1;
219
+ for (let attempt = 1; ; attempt++) {
220
+ let response;
221
+ let text;
222
+ try {
223
+ ({ response, text } = await this.#send(this.baseUrl + path, init));
224
+ } catch (error) {
225
+ if (attempt < attempts) {
226
+ await sleep(BACKOFF_MS * attempt);
227
+ continue;
228
+ }
229
+ throw error;
230
+ }
231
+ if (response.status >= 500 && attempt < attempts) {
232
+ await sleep(BACKOFF_MS * attempt);
233
+ continue;
234
+ }
235
+ return parseBody(response, text);
236
+ }
237
+ }
238
+
239
+ async #send(url, init) {
240
+ const controller = new AbortController();
241
+ const timer = setTimeout(() => controller.abort(), this.timeout);
242
+ try {
243
+ const response = await fetch(url, { ...init, signal: controller.signal });
244
+ const text = await response.text();
245
+ return { response, text };
246
+ } catch (cause) {
247
+ const timedOut = cause && cause.name === 'AbortError';
248
+ const detail = cause && cause.cause && cause.cause.message ? cause.cause.message : cause && cause.message;
249
+ throw new SmklogError(
250
+ timedOut ? `Request to ${url} timed out after ${this.timeout} ms` : `Request to ${url} failed: ${detail}`,
251
+ { status: 0, error: 'network_error', cause },
252
+ );
253
+ } finally {
254
+ clearTimeout(timer);
255
+ }
256
+ }
257
+ }
258
+
259
+ function parseBody(response, text) {
260
+ let parsed = null;
261
+ let parsable = text === '';
262
+ if (text !== '') {
263
+ try {
264
+ parsed = JSON.parse(text);
265
+ parsable = true;
266
+ } catch {
267
+ parsable = false;
268
+ }
269
+ }
270
+ if (!response.ok) throw errorFromResponse(response, parsable ? parsed : null, text);
271
+ if (!parsable) {
272
+ throw new SmklogError(`HTTP ${response.status} from ${response.url} is not JSON`, {
273
+ status: response.status,
274
+ error: 'invalid_response',
275
+ body: text,
276
+ });
277
+ }
278
+ return parsed;
279
+ }
280
+
281
+ function sleep(ms) {
282
+ return new Promise((resolve) => setTimeout(resolve, ms));
283
+ }
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "smklog",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Official JavaScript client for the SMKlog Quote API: live USPS, UPS, FedEx and DHL parcel rates from a US origin, a checkout handed to a human, and where that checkout stands. No API key.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./index.js",
8
+ "types": "./index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./index.d.ts",
12
+ "default": "./index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "index.js",
17
+ "index.d.ts",
18
+ "README.md"
19
+ ],
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "scripts": {
24
+ "test": "node --test"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/smklog/smklog-sdk.git",
29
+ "directory": "javascript"
30
+ },
31
+ "homepage": "https://smklog.com/developers/",
32
+ "bugs": {
33
+ "email": "info@smklog.com"
34
+ },
35
+ "keywords": [
36
+ "smklog",
37
+ "shipping",
38
+ "shipping-rates",
39
+ "parcel",
40
+ "postage",
41
+ "label",
42
+ "usps",
43
+ "ups",
44
+ "fedex",
45
+ "dhl",
46
+ "quote",
47
+ "checkout",
48
+ "api-client",
49
+ "sdk"
50
+ ]
51
+ }