@skalpgg/sdk 1.0.0 → 1.3.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/README.md +120 -4
- package/dist/cjs/index.d.ts +87 -8
- package/dist/cjs/index.js +244 -45
- package/dist/cjs/types.d.ts +689 -12
- package/dist/esm/index.d.ts +87 -8
- package/dist/esm/index.js +242 -44
- package/dist/esm/types.d.ts +689 -12
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# skalp
|
|
2
2
|
|
|
3
|
-
The official Skalp SDK for Node.js and edge runtimes: hosted checkout, subscriptions, webhook verification and license checks, with types for every response.
|
|
3
|
+
The official Skalp SDK for Node.js and edge runtimes: hosted checkout, subscriptions, your product catalog and key stock, webhook verification and license checks, with types for every response.
|
|
4
4
|
|
|
5
5
|
Full documentation with Next.js, Express, NestJS, Hono and Fastify examples: https://skalp.gg/docs/sdk
|
|
6
6
|
|
|
@@ -41,21 +41,121 @@ Create the key on the API keys page of your dashboard. Each key is bound to one
|
|
|
41
41
|
|
|
42
42
|
| Method | Scope |
|
|
43
43
|
| --- | --- |
|
|
44
|
-
| `checkout.create({ productId, customerEmail?, metadata?, couponCode?, returnUrl? })` | `checkout:write` |
|
|
44
|
+
| `checkout.create({ productId, quantity?, customerEmail?, metadata?, couponCode?, returnUrl?, customFields? })` | `checkout:write` |
|
|
45
45
|
| `checkout.get(id)` | `checkout:read` |
|
|
46
|
+
| `orders.list({ status?, productId?, email?, createdAfter?, createdBefore?, updatedSince?, limit?, offset? })` | `orders:read` |
|
|
46
47
|
| `orders.get(id)` | `orders:read` |
|
|
47
|
-
| `
|
|
48
|
+
| `orders.cancel(id)` | `orders:write` |
|
|
49
|
+
| `orders.replace(id, { serialIds?, values?, reason? })` | `orders:write` |
|
|
50
|
+
| `products.list({ status? })` | `products:read` |
|
|
51
|
+
| `products.get(idOrSlug)` | `products:read` |
|
|
52
|
+
| `products.create({ name, price, fulfillmentType?, submit?, ... })` | `products:write` |
|
|
53
|
+
| `products.update(idOrSlug, { price?, status?: 'DRAFT' \| 'ARCHIVED', ... })` | `products:write` |
|
|
54
|
+
| `products.archive(idOrSlug)` | `products:write` |
|
|
55
|
+
| `products.submit(idOrSlug)` | `products:write` |
|
|
56
|
+
| `products.stock(idOrSlug)` | `products:read` |
|
|
57
|
+
| `products.listSerials(idOrSlug, { status?, limit?, offset? })` | `products:write` |
|
|
58
|
+
| `products.addSerials(idOrSlug, { serials?, text?, delimiter? })` | `products:write` |
|
|
59
|
+
| `products.deleteSerial(idOrSlug, serialId)` | `products:write` |
|
|
60
|
+
| `categories.list()` | `products:read` |
|
|
61
|
+
| `categories.create({ name })` / `categories.update(id, { name?, sortOrder? })` / `categories.delete(id)` | `products:write` |
|
|
62
|
+
| `groups.list()` / `groups.get(id)` | `products:read` |
|
|
63
|
+
| `groups.create({ title, productIds, ... })` / `groups.update(id, { ... })` / `groups.delete(id)` | `products:write` |
|
|
64
|
+
| `coupons.list()` / `coupons.get(id)` | `coupons:read` |
|
|
65
|
+
| `coupons.create({ code, discountType?, discountPercent?, discountAmount?, validFrom?, validTo?, maxUses?, ... })` / `coupons.update(id, { ... })` / `coupons.delete(id)` | `coupons:write` |
|
|
66
|
+
| `customers.list({ search?, limit?, offset? })` / `customers.get(id)` | `customers:read` |
|
|
67
|
+
| `customers.create({ email, name?, note? })` / `customers.update(id, { name?, note? })` / `customers.delete(id)` | `customers:write` |
|
|
68
|
+
| `customers.adjustBalance(id, { amount, reason? })` | `customers:write` |
|
|
69
|
+
| `feedback.list({ productId?, rating?, limit?, offset? })` / `feedback.get(id)` | `feedback:read` |
|
|
70
|
+
| `feedback.reply(id, { message })` / `feedback.appeal(id, { reason })` | `feedback:write` |
|
|
71
|
+
| `queries.list({ status?, limit?, offset? })` / `queries.get(id)` | `queries:read` |
|
|
72
|
+
| `queries.reply(id, { message })` / `queries.close(id)` / `queries.reopen(id)` | `queries:write` |
|
|
73
|
+
| `blacklist.list({ type? })` / `whitelist.list({ type? })` | `blacklist:read` |
|
|
74
|
+
| `blacklist.add({ type, value, note? })` / `whitelist.add({ ... })` / `blacklist.remove(id)` / `whitelist.remove(id)` | `blacklist:write` |
|
|
48
75
|
| `subscriptions.createCheckout({ productId \| productSlug, externalRef?, customerEmail?, currency?, ... })` | `checkout:write` |
|
|
49
76
|
| `subscriptions.getCheckout(id)` | `checkout:read` |
|
|
50
77
|
| `subscriptions.list({ externalRef?, status?, updatedSince?, limit?, offset? })` | `subscriptions:read` |
|
|
51
78
|
| `subscriptions.get(id)` | `subscriptions:read` |
|
|
79
|
+
| `subscriptions.changePlan(id, { productId \| productSlug })` | `subscriptions:write` |
|
|
52
80
|
| `subscriptions.cancel(id)` | `subscriptions:write` |
|
|
81
|
+
| `subscriptions.createUsageCharge(id, { amount, currency, description, idempotencyKey, periodStart, periodEnd })` | `subscriptions:write` |
|
|
53
82
|
| `subscriptions.listInvoices(id)` | `subscriptions:read` |
|
|
83
|
+
| `coins.createCheckout({ steamHex, coins, idempotencyKey?, returnUrl? })` | `coins:purchase` |
|
|
84
|
+
| `coins.getCheckoutStatus(reference)` | `coins:read` |
|
|
85
|
+
| `licenses.validate({ key, productId?, hardwareId? })` | `licenses:validate` |
|
|
86
|
+
| `licenses.resetDevices({ key, hardwareId? })` | `licenses:write` |
|
|
54
87
|
| `licenses.verify({ licenseKey, serverIp, serverKey, serverPort? })` | none |
|
|
55
88
|
| `licenses.heartbeat({ licenseKey, serverIp })` | none |
|
|
56
89
|
| `webhooks.constructEvent(rawBody, headers)` | none |
|
|
57
90
|
| `webhooks.verify(rawBody, headers)` | none |
|
|
58
91
|
|
|
92
|
+
`products:write` does not include `products:read`: a key that manages the catalog usually needs both. Every catalog call counts against the key's rate limit.
|
|
93
|
+
|
|
94
|
+
## Catalog
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
const product = await skalp.products.create({
|
|
98
|
+
name: 'Aurora Pro key',
|
|
99
|
+
price: 19.5,
|
|
100
|
+
fulfillmentType: 'SERIAL',
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const { added, skipped, stock } = await skalp.products.addSerials(product.id, {
|
|
104
|
+
text: await readFile('keys.txt', 'utf8'),
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
await skalp.products.submit(product.id)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
A product starts as a `DRAFT`. `submit` sends it to review, and the review sets `ACTIVE` or `REJECTED`; read the product to see which. `update` accepts only `DRAFT` and `ARCHIVED` as a `status`, and `archive` takes a product off sale without deleting anything. Pass `null` to `update` to clear `comparePrice`, `storeCategoryId`, `dynamicUrl` or `licenseDurationDays`.
|
|
111
|
+
|
|
112
|
+
`addSerials` splits `text` on new lines unless you pass `delimiter` (`'comma'`, `'semicolon'` or any other string). A key the product already has is counted in `skipped` and not added again. `listSerials` returns the plain key values, which is why it needs `products:write`.
|
|
113
|
+
|
|
114
|
+
## Orders and customers
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
const { data: orders } = await skalp.orders.list({ status: 'COMPLETED', updatedSince: lastSync, limit: 100 })
|
|
118
|
+
|
|
119
|
+
await skalp.orders.cancel(order.id)
|
|
120
|
+
|
|
121
|
+
const { replacements } = await skalp.orders.replace(order.id, { reason: 'Key does not activate' })
|
|
122
|
+
|
|
123
|
+
await skalp.customers.adjustBalance(customer.id, { amount: 10, reason: 'Goodwill credit' })
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`cancel` works only on a `PENDING` order and returns it as `CANCELED`. `replace` swaps the delivered keys of a completed order: with no `serialIds` it replaces every key, and with no `values` it draws the new ones from stock (`values` is required for keys that come from your server). The replacement keys come back in `replacements[].value`. `orders.get` also returns `serialsReplaced` and the `replacements` history.
|
|
127
|
+
|
|
128
|
+
`updatedSince` lists oldest change first, so you can page through it and keep the last `updatedAt` as your cursor; without it, newest orders come first. Amounts are in major units (`19.5`), balances cannot go below zero, and a customer with a balance cannot be deleted.
|
|
129
|
+
|
|
130
|
+
Coupons take `validFrom`, `validTo` as an ISO string or a `Date`; `coupons.update` accepts `null` to clear `validTo`, `maxUses`, `minPurchaseAmount` and `description`.
|
|
131
|
+
|
|
132
|
+
## Support and access rules
|
|
133
|
+
|
|
134
|
+
`feedback` and `queries` expose product reviews and buyer conversations. `queries.reply`, `close` and `reopen` return the whole conversation with its `messages`. `blacklist` and `whitelist` use the same calls and the same `blacklist:read` and `blacklist:write` scopes; a whitelisted buyer is never blocked by a blacklist rule.
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
await skalp.blacklist.add({ type: 'EMAIL', value: 'chargeback@example.com', note: 'Disputed order 1042' })
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`type` is `EMAIL`, `IP`, `COUNTRY`, `ISP`, `ASN` or `HOST`. `blacklist.list` and `whitelist.list` return every rule as one array, not a page.
|
|
141
|
+
|
|
142
|
+
## Usage charges
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
const charge = await skalp.subscriptions.createUsageCharge(subscription.id, {
|
|
146
|
+
amount: 12.34,
|
|
147
|
+
currency: 'EUR',
|
|
148
|
+
description: 'Traffic over your plan, September 2026',
|
|
149
|
+
idempotencyKey: `overage:${tenantId}:2026-09`,
|
|
150
|
+
periodStart: new Date('2026-09-01T00:00:00Z'),
|
|
151
|
+
periodEnd: new Date('2026-10-01T00:00:00Z'),
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
if (charge.status === 'open') await emailPaymentLink(charge.hostedUrl)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The same `idempotencyKey` always returns the same charge, so retry with it after a timeout or a 5xx.
|
|
158
|
+
|
|
59
159
|
## Webhooks
|
|
60
160
|
|
|
61
161
|
Pass the raw request body, not parsed JSON, together with the request headers. `constructEvent` checks the `Skalp-Signature` header, an HMAC-SHA256 over the timestamp and the exact bytes Skalp sent, and rejects deliveries older than 5 minutes.
|
|
@@ -74,8 +174,24 @@ export async function POST(request: Request) {
|
|
|
74
174
|
|
|
75
175
|
A webhook with the default events receives every purchase as both `order.paid` and its legacy name `purchase.completed`. Handle `order.paid` and deduplicate on `data.idempotencyKey`.
|
|
76
176
|
|
|
177
|
+
`order.created`, `order.updated`, `order.canceled`, `order.replaced`, `query.created`, `query.replied`, `feedback.received` and the subscription events `subscription.updated`, `subscription.trial_started`, `subscription.trial_ended` and `subscription.upcoming` are not in the default list either: subscribe the webhook to them by name, and `event.type` narrows `event.data` to the right payload. `order.created`, `order.updated` and `order.canceled` carry the order as `orders.get` returns it, plus `previousStatus`; `order.replaced` adds `reason` and the new keys in `replacements[].key`. The three trial and upcoming events are accepted by the API but the backend does not send them yet.
|
|
178
|
+
|
|
179
|
+
`product.created`, `product.updated` and `product.stock` are not in the default list: subscribe the webhook to them by name. `product.stock` carries `stock`, `previousStock` and `change` whenever the number of available keys changes.
|
|
180
|
+
|
|
77
181
|
## Errors
|
|
78
182
|
|
|
79
|
-
API
|
|
183
|
+
A refusal from the API throws `SkalpError` with `status`, `code` (for example `STORE_REQUIRED`) and the parsed `body`. A network failure or a timeout throws `SkalpConnectionError`, naming the call and keeping the original in `cause`, so the two are easy to tell apart:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
try {
|
|
187
|
+
await skalp.orders.get(id)
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (error instanceof SkalpError) return reportRefusal(error.code, error.status)
|
|
190
|
+
if (error instanceof SkalpConnectionError) return retryLater()
|
|
191
|
+
throw error
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
A `429` is retried up to `maxRetries` times (default 2) when the limit resets within 10 seconds. `constructEvent` throws `SkalpSignatureError` with `reason: 'stale' | 'invalid'`: `'stale'` means the delivery is outside the 5 minute replay window, `'invalid'` means the bytes or the secret do not match.
|
|
80
196
|
|
|
81
197
|
The constructor throws if it finds an API key in a browser, so the mistake shows up the first time you run the code rather than after a secret key has shipped to users. Pass `dangerouslyAllowBrowser: true` to override it.
|
package/dist/cjs/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { CheckoutSession, CheckoutSessionStatus, CreateCheckoutParams, CreateSubscriptionCheckoutParams, HeartbeatParams, Invoice, LicenseVerification, ListSubscriptionsParams, Order, Product, SkalpEvent, Subscription, SubscriptionCheckout, SubscriptionCheckoutStatus, SubscriptionList, VerifyLicenseParams } from './types.js';
|
|
1
|
+
import type { AccessRule, AddAccessRuleParams, AddSerialsParams, AddSerialsResult, AdjustCustomerBalanceParams, AppealFeedbackParams, ChangeSubscriptionPlanParams, CheckoutSession, CheckoutSessionStatus, CoinCheckout, CoinCheckoutStatus, Coupon, CreateCheckoutParams, CreateCoinCheckoutParams, CreateCouponParams, CreateCustomerParams, CreateProductGroupParams, CreateProductParams, CreateStoreCategoryParams, CreateSubscriptionCheckoutParams, CreateUsageChargeParams, Customer, CustomerDetails, CustomerList, Deleted, Feedback, FeedbackList, HeartbeatParams, Invoice, LicenseKeyInfo, LicenseValidation, LicenseVerification, ListAccessRulesParams, ListCustomersParams, ListFeedbackParams, ListOrdersParams, ListProductsParams, ListQueriesParams, ListSerialsParams, ListSubscriptionsParams, Order, OrderKeysReplaced, OrderList, Product, ProductGroup, ProductStock, ReplaceOrderKeysParams, ReplyParams, SerialList, SkalpEvent, StoreCategory, StoreCategoryWithCount, Subscription, SubscriptionCheckout, SubscriptionCheckoutStatus, SubscriptionList, SupportQueryDetails, SupportQueryList, ResetLicenseDevicesParams, UpdateCouponParams, UpdateCustomerParams, UpdateProductGroupParams, UpdateProductParams, UpdateStoreCategoryParams, UsageCharge, ValidateLicenseParams, VerifyLicenseParams } from './types.js';
|
|
2
2
|
export type * from './types.js';
|
|
3
|
+
export declare const VERSION = "1.3.0";
|
|
3
4
|
export interface SkalpOptions {
|
|
4
5
|
apiKey?: string;
|
|
5
6
|
webhookSecret?: string;
|
|
@@ -19,35 +20,113 @@ export declare class SkalpError extends Error {
|
|
|
19
20
|
readonly body: unknown;
|
|
20
21
|
constructor(message: string, status: number, code?: string, body?: unknown);
|
|
21
22
|
}
|
|
23
|
+
export declare class SkalpConnectionError extends Error {
|
|
24
|
+
constructor(method: string, path: string, cause: unknown);
|
|
25
|
+
}
|
|
26
|
+
export type SignatureFailure = 'stale' | 'invalid';
|
|
22
27
|
export declare class SkalpSignatureError extends Error {
|
|
23
|
-
|
|
28
|
+
readonly reason: SignatureFailure;
|
|
29
|
+
constructor(reason?: SignatureFailure);
|
|
24
30
|
}
|
|
25
31
|
export declare class Skalp {
|
|
26
32
|
#private;
|
|
27
33
|
constructor(options?: string | SkalpOptions);
|
|
28
34
|
readonly checkout: {
|
|
29
|
-
create: (
|
|
35
|
+
create: ({ productId, quantity, customerEmail, metadata, couponCode, returnUrl, customFields }: CreateCheckoutParams) => Promise<CheckoutSession>;
|
|
30
36
|
get: (id: string) => Promise<CheckoutSessionStatus>;
|
|
31
37
|
};
|
|
32
38
|
readonly orders: {
|
|
39
|
+
list: ({ status, productId, email, createdAfter, createdBefore, updatedSince, limit, offset }?: ListOrdersParams) => Promise<OrderList>;
|
|
33
40
|
get: (id: string) => Promise<Order>;
|
|
41
|
+
cancel: (id: string) => Promise<Order>;
|
|
42
|
+
replace: (id: string, { serialIds, values, reason }?: ReplaceOrderKeysParams) => Promise<OrderKeysReplaced>;
|
|
34
43
|
};
|
|
35
44
|
readonly products: {
|
|
36
|
-
list: () => Promise<Product[]>;
|
|
45
|
+
list: ({ status }?: ListProductsParams) => Promise<Product[]>;
|
|
46
|
+
get: (idOrSlug: string) => Promise<Product>;
|
|
47
|
+
create: (params: CreateProductParams) => Promise<Product>;
|
|
48
|
+
update: (idOrSlug: string, params: UpdateProductParams) => Promise<Product>;
|
|
49
|
+
archive: (idOrSlug: string) => Promise<Product>;
|
|
50
|
+
submit: (idOrSlug: string) => Promise<Product>;
|
|
51
|
+
stock: (idOrSlug: string) => Promise<ProductStock>;
|
|
52
|
+
listSerials: (idOrSlug: string, { status, limit, offset }?: ListSerialsParams) => Promise<SerialList>;
|
|
53
|
+
addSerials: (idOrSlug: string, { serials, text, delimiter }: AddSerialsParams) => Promise<AddSerialsResult>;
|
|
54
|
+
deleteSerial: (idOrSlug: string, serialId: string) => Promise<Deleted>;
|
|
55
|
+
};
|
|
56
|
+
readonly categories: {
|
|
57
|
+
list: () => Promise<StoreCategoryWithCount[]>;
|
|
58
|
+
create: ({ name }: CreateStoreCategoryParams) => Promise<StoreCategory>;
|
|
59
|
+
update: (id: string, { name, sortOrder }: UpdateStoreCategoryParams) => Promise<StoreCategory>;
|
|
60
|
+
delete: (id: string) => Promise<Deleted>;
|
|
61
|
+
};
|
|
62
|
+
readonly groups: {
|
|
63
|
+
list: () => Promise<ProductGroup[]>;
|
|
64
|
+
get: (id: string) => Promise<ProductGroup>;
|
|
65
|
+
create: (params: CreateProductGroupParams) => Promise<ProductGroup>;
|
|
66
|
+
update: (id: string, params: UpdateProductGroupParams) => Promise<ProductGroup>;
|
|
67
|
+
delete: (id: string) => Promise<Deleted>;
|
|
68
|
+
};
|
|
69
|
+
readonly coupons: {
|
|
70
|
+
list: () => Promise<Coupon[]>;
|
|
71
|
+
get: (id: string) => Promise<Coupon>;
|
|
72
|
+
create: (params: CreateCouponParams) => Promise<Coupon>;
|
|
73
|
+
update: (id: string, params: UpdateCouponParams) => Promise<Coupon>;
|
|
74
|
+
delete: (id: string) => Promise<Deleted>;
|
|
75
|
+
};
|
|
76
|
+
readonly customers: {
|
|
77
|
+
list: ({ search, limit, offset }?: ListCustomersParams) => Promise<CustomerList>;
|
|
78
|
+
get: (id: string) => Promise<CustomerDetails>;
|
|
79
|
+
create: ({ email, name, note }: CreateCustomerParams) => Promise<Customer>;
|
|
80
|
+
update: (id: string, { name, note }: UpdateCustomerParams) => Promise<Customer>;
|
|
81
|
+
delete: (id: string) => Promise<Deleted>;
|
|
82
|
+
adjustBalance: (id: string, { amount, reason }: AdjustCustomerBalanceParams) => Promise<Customer>;
|
|
83
|
+
};
|
|
84
|
+
readonly feedback: {
|
|
85
|
+
list: ({ productId, rating, limit, offset }?: ListFeedbackParams) => Promise<FeedbackList>;
|
|
86
|
+
get: (id: string) => Promise<Feedback>;
|
|
87
|
+
reply: (id: string, { message }: ReplyParams) => Promise<Feedback>;
|
|
88
|
+
appeal: (id: string, { reason }: AppealFeedbackParams) => Promise<Feedback>;
|
|
89
|
+
};
|
|
90
|
+
readonly queries: {
|
|
91
|
+
list: ({ status, limit, offset }?: ListQueriesParams) => Promise<SupportQueryList>;
|
|
92
|
+
get: (id: string) => Promise<SupportQueryDetails>;
|
|
93
|
+
reply: (id: string, { message }: ReplyParams) => Promise<SupportQueryDetails>;
|
|
94
|
+
close: (id: string) => Promise<SupportQueryDetails>;
|
|
95
|
+
reopen: (id: string) => Promise<SupportQueryDetails>;
|
|
96
|
+
};
|
|
97
|
+
readonly blacklist: {
|
|
98
|
+
list: ({ type }?: ListAccessRulesParams) => Promise<AccessRule[]>;
|
|
99
|
+
add: ({ type, value, note }: AddAccessRuleParams) => Promise<AccessRule>;
|
|
100
|
+
remove: (id: string) => Promise<Deleted>;
|
|
101
|
+
};
|
|
102
|
+
readonly whitelist: {
|
|
103
|
+
list: ({ type }?: ListAccessRulesParams) => Promise<AccessRule[]>;
|
|
104
|
+
add: ({ type, value, note }: AddAccessRuleParams) => Promise<AccessRule>;
|
|
105
|
+
remove: (id: string) => Promise<Deleted>;
|
|
106
|
+
};
|
|
107
|
+
readonly coins: {
|
|
108
|
+
createCheckout: ({ steamHex, coins, idempotencyKey, returnUrl }: CreateCoinCheckoutParams) => Promise<CoinCheckout>;
|
|
109
|
+
getCheckoutStatus: (reference: string) => Promise<CoinCheckoutStatus>;
|
|
37
110
|
};
|
|
38
111
|
readonly subscriptions: {
|
|
39
|
-
createCheckout: (
|
|
112
|
+
createCheckout: ({ productId, productSlug, customerEmail, externalRef, metadata, currency, returnUrl, }: CreateSubscriptionCheckoutParams) => Promise<SubscriptionCheckout>;
|
|
40
113
|
getCheckout: (id: string) => Promise<SubscriptionCheckoutStatus>;
|
|
41
|
-
list: (
|
|
114
|
+
list: ({ externalRef, status, updatedSince, limit, offset }?: ListSubscriptionsParams) => Promise<SubscriptionList>;
|
|
42
115
|
get: (id: string) => Promise<Subscription>;
|
|
116
|
+
changePlan: (id: string, { productId, productSlug }: ChangeSubscriptionPlanParams) => Promise<Subscription>;
|
|
43
117
|
cancel: (id: string) => Promise<Subscription>;
|
|
118
|
+
createUsageCharge: (id: string, { amount, currency, description, idempotencyKey, periodStart, periodEnd }: CreateUsageChargeParams) => Promise<UsageCharge>;
|
|
44
119
|
listInvoices: (id: string) => Promise<Invoice[]>;
|
|
45
120
|
};
|
|
46
121
|
readonly licenses: {
|
|
47
|
-
verify: (
|
|
48
|
-
heartbeat: (
|
|
122
|
+
verify: ({ licenseKey, serverIp, serverKey, serverPort }: VerifyLicenseParams) => Promise<LicenseVerification>;
|
|
123
|
+
heartbeat: ({ licenseKey, serverIp }: HeartbeatParams) => Promise<{
|
|
49
124
|
valid: boolean;
|
|
50
125
|
}>;
|
|
126
|
+
validate: ({ key, productId, hardwareId }: ValidateLicenseParams) => Promise<LicenseValidation>;
|
|
127
|
+
resetDevices: ({ key, hardwareId }: ResetLicenseDevicesParams) => Promise<{
|
|
128
|
+
license: LicenseKeyInfo;
|
|
129
|
+
}>;
|
|
51
130
|
};
|
|
52
131
|
readonly webhooks: {
|
|
53
132
|
verify: (payload: WebhookPayload, signature: SignatureSource, secret?: string) => Promise<boolean>;
|
package/dist/cjs/index.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.Skalp = exports.SkalpSignatureError = exports.SkalpError = void 0;
|
|
3
|
+
exports.Skalp = exports.SkalpSignatureError = exports.SkalpConnectionError = exports.SkalpError = exports.VERSION = void 0;
|
|
4
|
+
exports.VERSION = '1.3.0';
|
|
4
5
|
const SIGNATURE_HEADER = 'skalp-signature';
|
|
5
6
|
const SIGNATURE_TOLERANCE_SECONDS = 300;
|
|
7
|
+
const USER_AGENT = `skalp-node/${exports.VERSION}`;
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
9
|
+
const MAX_RETRY_WAIT_SECONDS = 10;
|
|
6
10
|
class SkalpError extends Error {
|
|
7
11
|
status;
|
|
8
12
|
code;
|
|
@@ -16,16 +20,69 @@ class SkalpError extends Error {
|
|
|
16
20
|
}
|
|
17
21
|
}
|
|
18
22
|
exports.SkalpError = SkalpError;
|
|
23
|
+
class SkalpConnectionError extends Error {
|
|
24
|
+
constructor(method, path, cause) {
|
|
25
|
+
super(`Skalp: ${method} ${path}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
26
|
+
this.name = 'SkalpConnectionError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
exports.SkalpConnectionError = SkalpConnectionError;
|
|
19
30
|
class SkalpSignatureError extends Error {
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
reason;
|
|
32
|
+
constructor(reason = 'invalid') {
|
|
33
|
+
super(reason === 'stale'
|
|
34
|
+
? 'Skalp: webhook signature is outside the replay window'
|
|
35
|
+
: 'Skalp: invalid webhook signature');
|
|
22
36
|
this.name = 'SkalpSignatureError';
|
|
37
|
+
this.reason = reason;
|
|
23
38
|
}
|
|
24
39
|
}
|
|
25
40
|
exports.SkalpSignatureError = SkalpSignatureError;
|
|
26
41
|
const env = (name) => globalThis.process?.env?.[name];
|
|
27
42
|
const segment = (id) => encodeURIComponent(id);
|
|
28
|
-
const
|
|
43
|
+
const isoDate = (value) => (value instanceof Date ? value.toISOString() : value);
|
|
44
|
+
const optionalDate = (value) => value instanceof Date ? value.toISOString() : value;
|
|
45
|
+
const PRODUCT_FIELDS = [
|
|
46
|
+
'name',
|
|
47
|
+
'slug',
|
|
48
|
+
'description',
|
|
49
|
+
'shortDescription',
|
|
50
|
+
'price',
|
|
51
|
+
'comparePrice',
|
|
52
|
+
'category',
|
|
53
|
+
'categoryId',
|
|
54
|
+
'storeCategoryId',
|
|
55
|
+
'tags',
|
|
56
|
+
'images',
|
|
57
|
+
'videoUrl',
|
|
58
|
+
'fulfillmentType',
|
|
59
|
+
'billingInterval',
|
|
60
|
+
'trialPeriodDays',
|
|
61
|
+
'dynamicUrl',
|
|
62
|
+
'licenseDeviceLimit',
|
|
63
|
+
'licenseDurationDays',
|
|
64
|
+
];
|
|
65
|
+
const COUPON_FIELDS = [
|
|
66
|
+
'discountType',
|
|
67
|
+
'discountPercent',
|
|
68
|
+
'discountAmount',
|
|
69
|
+
'productIds',
|
|
70
|
+
'disabledWithVolumeDiscounts',
|
|
71
|
+
'description',
|
|
72
|
+
'maxUses',
|
|
73
|
+
'minPurchaseAmount',
|
|
74
|
+
'oneTimePerUser',
|
|
75
|
+
];
|
|
76
|
+
const GROUP_FIELDS = ['title', 'productIds', 'description', 'image', 'sortPriority', 'unlisted'];
|
|
77
|
+
function pick(source, keys) {
|
|
78
|
+
const picked = {};
|
|
79
|
+
for (const key of keys) {
|
|
80
|
+
const value = source[key];
|
|
81
|
+
if (value !== undefined)
|
|
82
|
+
picked[key] = value;
|
|
83
|
+
}
|
|
84
|
+
return picked;
|
|
85
|
+
}
|
|
29
86
|
class Skalp {
|
|
30
87
|
#apiKey;
|
|
31
88
|
#webhookSecret;
|
|
@@ -38,7 +95,7 @@ class Skalp {
|
|
|
38
95
|
this.#apiKey = o.apiKey ?? env('SKALP_API_KEY');
|
|
39
96
|
this.#webhookSecret = o.webhookSecret ?? env('SKALP_WEBHOOK_SECRET');
|
|
40
97
|
this.#baseUrl = (o.baseUrl ?? env('SKALP_API_URL') ?? 'https://api.skalp.gg').replace(/\/+$/, '');
|
|
41
|
-
this.#timeout = o.timeout
|
|
98
|
+
this.#timeout = o.timeout && o.timeout > 0 ? o.timeout : DEFAULT_TIMEOUT_MS;
|
|
42
99
|
this.#maxRetries = o.maxRetries ?? 2;
|
|
43
100
|
this.#fetch = o.fetch ?? ((...args) => fetch(...args));
|
|
44
101
|
if (this.#apiKey && 'window' in globalThis && 'document' in globalThis && !o.dangerouslyAllowBrowser) {
|
|
@@ -46,62 +103,198 @@ class Skalp {
|
|
|
46
103
|
}
|
|
47
104
|
}
|
|
48
105
|
checkout = {
|
|
49
|
-
create: (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
body: { items: [{ productId, quantity: 1 }], ...rest },
|
|
53
|
-
});
|
|
54
|
-
},
|
|
106
|
+
create: ({ productId, quantity, customerEmail, metadata, couponCode, returnUrl, customFields }) => this.#request('POST', '/v1/checkout/sessions', {
|
|
107
|
+
body: { items: [{ productId, quantity }], customerEmail, metadata, couponCode, returnUrl, customFields },
|
|
108
|
+
}),
|
|
55
109
|
get: (id) => this.#request('GET', `/v1/checkout/sessions/${segment(id)}`),
|
|
56
110
|
};
|
|
57
111
|
orders = {
|
|
112
|
+
list: ({ status, productId, email, createdAfter, createdBefore, updatedSince, limit, offset } = {}) => this.#request('GET', '/v1/orders', {
|
|
113
|
+
query: {
|
|
114
|
+
status,
|
|
115
|
+
productId,
|
|
116
|
+
email,
|
|
117
|
+
createdAfter: optionalDate(createdAfter),
|
|
118
|
+
createdBefore: optionalDate(createdBefore),
|
|
119
|
+
updatedSince: optionalDate(updatedSince),
|
|
120
|
+
limit,
|
|
121
|
+
offset,
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
58
124
|
get: (id) => this.#request('GET', `/v1/orders/${segment(id)}`),
|
|
125
|
+
cancel: (id) => this.#request('POST', `/v1/orders/${segment(id)}/cancel`),
|
|
126
|
+
replace: (id, { serialIds, values, reason } = {}) => this.#request('POST', `/v1/orders/${segment(id)}/replacement`, {
|
|
127
|
+
body: { serialIds, values, reason },
|
|
128
|
+
}),
|
|
59
129
|
};
|
|
60
130
|
products = {
|
|
61
|
-
list: () => this.#request('GET', '/v1/products'),
|
|
131
|
+
list: ({ status } = {}) => this.#request('GET', '/v1/products', { query: { status } }),
|
|
132
|
+
get: (idOrSlug) => this.#request('GET', `/v1/products/${segment(idOrSlug)}`),
|
|
133
|
+
create: (params) => this.#request('POST', '/v1/products', {
|
|
134
|
+
body: pick(params, [...PRODUCT_FIELDS, 'productType', 'submit']),
|
|
135
|
+
}),
|
|
136
|
+
update: (idOrSlug, params) => this.#request('PATCH', `/v1/products/${segment(idOrSlug)}`, {
|
|
137
|
+
body: pick(params, [...PRODUCT_FIELDS, 'status']),
|
|
138
|
+
}),
|
|
139
|
+
archive: (idOrSlug) => this.#request('DELETE', `/v1/products/${segment(idOrSlug)}`),
|
|
140
|
+
submit: (idOrSlug) => this.#request('POST', `/v1/products/${segment(idOrSlug)}/submit`),
|
|
141
|
+
stock: (idOrSlug) => this.#request('GET', `/v1/products/${segment(idOrSlug)}/stock`),
|
|
142
|
+
listSerials: (idOrSlug, { status, limit, offset } = {}) => this.#request('GET', `/v1/products/${segment(idOrSlug)}/serials`, {
|
|
143
|
+
query: { status, limit, offset },
|
|
144
|
+
}),
|
|
145
|
+
addSerials: (idOrSlug, { serials, text, delimiter }) => this.#request('POST', `/v1/products/${segment(idOrSlug)}/serials`, {
|
|
146
|
+
body: { serials, text, delimiter },
|
|
147
|
+
}),
|
|
148
|
+
deleteSerial: (idOrSlug, serialId) => this.#request('DELETE', `/v1/products/${segment(idOrSlug)}/serials/${segment(serialId)}`),
|
|
149
|
+
};
|
|
150
|
+
categories = {
|
|
151
|
+
list: () => this.#request('GET', '/v1/categories'),
|
|
152
|
+
create: ({ name }) => this.#request('POST', '/v1/categories', { body: { name } }),
|
|
153
|
+
update: (id, { name, sortOrder }) => this.#request('PATCH', `/v1/categories/${segment(id)}`, { body: { name, sortOrder } }),
|
|
154
|
+
delete: (id) => this.#request('DELETE', `/v1/categories/${segment(id)}`),
|
|
155
|
+
};
|
|
156
|
+
groups = {
|
|
157
|
+
list: () => this.#request('GET', '/v1/groups'),
|
|
158
|
+
get: (id) => this.#request('GET', `/v1/groups/${segment(id)}`),
|
|
159
|
+
create: (params) => this.#request('POST', '/v1/groups', { body: pick(params, GROUP_FIELDS) }),
|
|
160
|
+
update: (id, params) => this.#request('PATCH', `/v1/groups/${segment(id)}`, { body: pick(params, GROUP_FIELDS) }),
|
|
161
|
+
delete: (id) => this.#request('DELETE', `/v1/groups/${segment(id)}`),
|
|
162
|
+
};
|
|
163
|
+
coupons = {
|
|
164
|
+
list: () => this.#request('GET', '/v1/coupons'),
|
|
165
|
+
get: (id) => this.#request('GET', `/v1/coupons/${segment(id)}`),
|
|
166
|
+
create: (params) => this.#request('POST', '/v1/coupons', {
|
|
167
|
+
body: {
|
|
168
|
+
...pick(params, [...COUPON_FIELDS, 'code']),
|
|
169
|
+
validFrom: optionalDate(params.validFrom),
|
|
170
|
+
validTo: optionalDate(params.validTo),
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
update: (id, params) => this.#request('PATCH', `/v1/coupons/${segment(id)}`, {
|
|
174
|
+
body: {
|
|
175
|
+
...pick(params, [...COUPON_FIELDS, 'isActive']),
|
|
176
|
+
validFrom: optionalDate(params.validFrom),
|
|
177
|
+
validTo: optionalDate(params.validTo),
|
|
178
|
+
},
|
|
179
|
+
}),
|
|
180
|
+
delete: (id) => this.#request('DELETE', `/v1/coupons/${segment(id)}`),
|
|
181
|
+
};
|
|
182
|
+
customers = {
|
|
183
|
+
list: ({ search, limit, offset } = {}) => this.#request('GET', '/v1/customers', { query: { search, limit, offset } }),
|
|
184
|
+
get: (id) => this.#request('GET', `/v1/customers/${segment(id)}`),
|
|
185
|
+
create: ({ email, name, note }) => this.#request('POST', '/v1/customers', { body: { email, name, note } }),
|
|
186
|
+
update: (id, { name, note }) => this.#request('PATCH', `/v1/customers/${segment(id)}`, { body: { name, note } }),
|
|
187
|
+
delete: (id) => this.#request('DELETE', `/v1/customers/${segment(id)}`),
|
|
188
|
+
adjustBalance: (id, { amount, reason }) => this.#request('POST', `/v1/customers/${segment(id)}/balance`, { body: { amount, reason } }),
|
|
189
|
+
};
|
|
190
|
+
feedback = {
|
|
191
|
+
list: ({ productId, rating, limit, offset } = {}) => this.#request('GET', '/v1/feedback', { query: { productId, rating, limit, offset } }),
|
|
192
|
+
get: (id) => this.#request('GET', `/v1/feedback/${segment(id)}`),
|
|
193
|
+
reply: (id, { message }) => this.#request('POST', `/v1/feedback/${segment(id)}/reply`, { body: { message } }),
|
|
194
|
+
appeal: (id, { reason }) => this.#request('POST', `/v1/feedback/${segment(id)}/appeal`, { body: { reason } }),
|
|
195
|
+
};
|
|
196
|
+
queries = {
|
|
197
|
+
list: ({ status, limit, offset } = {}) => this.#request('GET', '/v1/queries', { query: { status, limit, offset } }),
|
|
198
|
+
get: (id) => this.#request('GET', `/v1/queries/${segment(id)}`),
|
|
199
|
+
reply: (id, { message }) => this.#request('POST', `/v1/queries/${segment(id)}/reply`, { body: { message } }),
|
|
200
|
+
close: (id) => this.#request('POST', `/v1/queries/${segment(id)}/close`),
|
|
201
|
+
reopen: (id) => this.#request('POST', `/v1/queries/${segment(id)}/reopen`),
|
|
202
|
+
};
|
|
203
|
+
blacklist = this.#accessRules('blacklist');
|
|
204
|
+
whitelist = this.#accessRules('whitelist');
|
|
205
|
+
#accessRules(list) {
|
|
206
|
+
return {
|
|
207
|
+
list: ({ type } = {}) => this.#request('GET', `/v1/${list}`, { query: { type } }),
|
|
208
|
+
add: ({ type, value, note }) => this.#request('POST', `/v1/${list}`, { body: { type, value, note } }),
|
|
209
|
+
remove: (id) => this.#request('DELETE', `/v1/${list}/${segment(id)}`),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
coins = {
|
|
213
|
+
createCheckout: ({ steamHex, coins, idempotencyKey, returnUrl }) => this.#request('POST', '/coins/checkout', {
|
|
214
|
+
body: { steamHex, coins, idempotencyKey, returnUrl },
|
|
215
|
+
}),
|
|
216
|
+
getCheckoutStatus: (reference) => this.#request('GET', `/coins/checkout/${segment(reference)}/status`),
|
|
62
217
|
};
|
|
63
218
|
subscriptions = {
|
|
64
|
-
createCheckout: (
|
|
219
|
+
createCheckout: ({ productId, productSlug, customerEmail, externalRef, metadata, currency, returnUrl, }) => this.#request('POST', '/v1/subscriptions/checkout', {
|
|
220
|
+
body: { productId, productSlug, customerEmail, externalRef, metadata, currency, returnUrl },
|
|
221
|
+
}),
|
|
65
222
|
getCheckout: (id) => this.#request('GET', `/v1/subscriptions/checkout/${segment(id)}`),
|
|
66
|
-
list: (
|
|
223
|
+
list: ({ externalRef, status, updatedSince, limit, offset } = {}) => this.#request('GET', '/v1/subscriptions', {
|
|
67
224
|
query: {
|
|
68
|
-
|
|
69
|
-
|
|
225
|
+
externalRef,
|
|
226
|
+
status,
|
|
227
|
+
updatedSince: updatedSince instanceof Date ? updatedSince.toISOString() : updatedSince,
|
|
228
|
+
limit,
|
|
229
|
+
offset,
|
|
70
230
|
},
|
|
71
231
|
}),
|
|
72
232
|
get: (id) => this.#request('GET', `/v1/subscriptions/${segment(id)}`),
|
|
233
|
+
changePlan: (id, { productId, productSlug }) => this.#request('POST', `/v1/subscriptions/${segment(id)}/plan`, {
|
|
234
|
+
body: { productId, productSlug },
|
|
235
|
+
}),
|
|
73
236
|
cancel: (id) => this.#request('DELETE', `/v1/subscriptions/${segment(id)}`),
|
|
237
|
+
createUsageCharge: (id, { amount, currency, description, idempotencyKey, periodStart, periodEnd }) => this.#request('POST', `/v1/subscriptions/${segment(id)}/usage-charges`, {
|
|
238
|
+
body: {
|
|
239
|
+
amount,
|
|
240
|
+
currency,
|
|
241
|
+
description,
|
|
242
|
+
idempotencyKey,
|
|
243
|
+
periodStart: isoDate(periodStart),
|
|
244
|
+
periodEnd: isoDate(periodEnd),
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
74
247
|
listInvoices: (id) => this.#request('GET', `/v1/subscriptions/${segment(id)}/invoices`).then((r) => r.data),
|
|
75
248
|
};
|
|
76
249
|
licenses = {
|
|
77
|
-
verify: (
|
|
78
|
-
|
|
250
|
+
verify: ({ licenseKey, serverIp, serverKey, serverPort }) => this.#request('POST', '/licenses/verify', {
|
|
251
|
+
body: { licenseKey, serverIp, serverKey, serverPort },
|
|
252
|
+
auth: false,
|
|
253
|
+
}),
|
|
254
|
+
heartbeat: ({ licenseKey, serverIp }) => this.#request('POST', '/licenses/heartbeat', {
|
|
255
|
+
body: { licenseKey, serverIp },
|
|
256
|
+
auth: false,
|
|
257
|
+
}),
|
|
258
|
+
// Keys sold with the "License key" delivery. Needs the licenses:validate
|
|
259
|
+
// scope; a key with only that scope may ship inside your app.
|
|
260
|
+
validate: ({ key, productId, hardwareId }) => this.#request('POST', '/v1/licenses/validate', {
|
|
261
|
+
body: { key, productId, hardwareId },
|
|
262
|
+
}),
|
|
263
|
+
// Frees the machines a key is bound to. Needs licenses:write; call it
|
|
264
|
+
// from your server only.
|
|
265
|
+
resetDevices: ({ key, hardwareId }) => this.#request('POST', '/v1/licenses/devices/reset', {
|
|
266
|
+
body: { key, hardwareId },
|
|
267
|
+
}),
|
|
79
268
|
};
|
|
80
269
|
webhooks = {
|
|
81
|
-
verify: async (payload, signature, secret) =>
|
|
82
|
-
const key = secret ?? this.#webhookSecret;
|
|
83
|
-
if (!key) {
|
|
84
|
-
throw new Error('Skalp: missing webhook secret. Pass it as the third argument or set SKALP_WEBHOOK_SECRET.');
|
|
85
|
-
}
|
|
86
|
-
const encoder = new TextEncoder();
|
|
87
|
-
const body = toBytes(payload, encoder);
|
|
88
|
-
const parsed = parseSignature(readSignature(signature));
|
|
89
|
-
if (!parsed || Math.abs(Date.now() / 1000 - parsed.timestamp) > SIGNATURE_TOLERANCE_SECONDS)
|
|
90
|
-
return false;
|
|
91
|
-
const prefix = encoder.encode(`${parsed.timestamp}.`);
|
|
92
|
-
const signed = new Uint8Array(prefix.length + body.length);
|
|
93
|
-
signed.set(prefix);
|
|
94
|
-
signed.set(body, prefix.length);
|
|
95
|
-
const hmac = await crypto.subtle.importKey('raw', encoder.encode(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
96
|
-
return crypto.subtle.verify('HMAC', hmac, parsed.signature, signed);
|
|
97
|
-
},
|
|
270
|
+
verify: async (payload, signature, secret) => (await this.#checkSignature(payload, signature, secret)) === undefined,
|
|
98
271
|
constructEvent: async (payload, signature, secret) => {
|
|
99
|
-
|
|
100
|
-
|
|
272
|
+
const failure = await this.#checkSignature(payload, signature, secret);
|
|
273
|
+
if (failure)
|
|
274
|
+
throw new SkalpSignatureError(failure);
|
|
101
275
|
const text = typeof payload === 'string' ? payload : new TextDecoder().decode(payload);
|
|
102
276
|
return JSON.parse(text);
|
|
103
277
|
},
|
|
104
278
|
};
|
|
279
|
+
async #checkSignature(payload, signature, secret) {
|
|
280
|
+
const key = secret ?? this.#webhookSecret;
|
|
281
|
+
if (!key) {
|
|
282
|
+
throw new Error('Skalp: missing webhook secret. Pass it as the third argument or set SKALP_WEBHOOK_SECRET.');
|
|
283
|
+
}
|
|
284
|
+
const encoder = new TextEncoder();
|
|
285
|
+
const body = toBytes(payload, encoder);
|
|
286
|
+
const parsed = parseSignature(readSignature(signature));
|
|
287
|
+
if (!parsed)
|
|
288
|
+
return 'invalid';
|
|
289
|
+
if (Math.abs(Date.now() / 1000 - parsed.timestamp) > SIGNATURE_TOLERANCE_SECONDS)
|
|
290
|
+
return 'stale';
|
|
291
|
+
const prefix = encoder.encode(`${parsed.timestamp}.`);
|
|
292
|
+
const signed = new Uint8Array(prefix.length + body.length);
|
|
293
|
+
signed.set(prefix);
|
|
294
|
+
signed.set(body, prefix.length);
|
|
295
|
+
const hmac = await crypto.subtle.importKey('raw', encoder.encode(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
296
|
+
return (await crypto.subtle.verify('HMAC', hmac, parsed.signature, signed)) ? undefined : 'invalid';
|
|
297
|
+
}
|
|
105
298
|
async #request(method, path, { body, query, auth = true } = {}) {
|
|
106
299
|
if (auth && !this.#apiKey) {
|
|
107
300
|
throw new Error('Skalp: missing API key. Pass it to new Skalp(apiKey) or set SKALP_API_KEY.');
|
|
@@ -111,18 +304,24 @@ class Skalp {
|
|
|
111
304
|
if (value !== undefined && value !== null)
|
|
112
305
|
url.searchParams.set(name, String(value));
|
|
113
306
|
}
|
|
114
|
-
const headers = { accept: 'application/json' };
|
|
307
|
+
const headers = { accept: 'application/json', 'user-agent': USER_AGENT };
|
|
115
308
|
if (auth)
|
|
116
309
|
headers['x-api-key'] = this.#apiKey;
|
|
117
310
|
if (body !== undefined)
|
|
118
311
|
headers['content-type'] = 'application/json';
|
|
119
312
|
for (let attempt = 0;; attempt++) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
313
|
+
let res;
|
|
314
|
+
try {
|
|
315
|
+
res = await this.#fetch(url, {
|
|
316
|
+
method,
|
|
317
|
+
headers,
|
|
318
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
319
|
+
signal: AbortSignal.timeout(this.#timeout),
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
catch (cause) {
|
|
323
|
+
throw new SkalpConnectionError(method, path, cause);
|
|
324
|
+
}
|
|
126
325
|
if (res.status === 429 && attempt < this.#maxRetries) {
|
|
127
326
|
const wait = retryAfterSeconds(res, attempt);
|
|
128
327
|
if (wait <= MAX_RETRY_WAIT_SECONDS) {
|
|
@@ -188,7 +387,7 @@ function retryAfterSeconds(res, attempt) {
|
|
|
188
387
|
const reset = Number(res.headers.get('x-ratelimit-reset'));
|
|
189
388
|
if (reset > 0)
|
|
190
389
|
return Math.max(reset - Date.now() / 1000, 0.5);
|
|
191
|
-
return 2 ** attempt;
|
|
390
|
+
return 2 ** Math.min(attempt, 5);
|
|
192
391
|
}
|
|
193
392
|
function toError(status, body) {
|
|
194
393
|
const b = (body && typeof body === 'object' ? body : {});
|