@strimz/sdk 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/LICENSE +21 -0
- package/README.md +111 -0
- package/dist/browser-client-DlZg0Aae.d.cts +223 -0
- package/dist/browser-client-DlZg0Aae.d.ts +223 -0
- package/dist/browser-client.cjs +443 -0
- package/dist/browser-client.cjs.map +1 -0
- package/dist/browser-client.d.cts +4 -0
- package/dist/browser-client.d.ts +4 -0
- package/dist/browser-client.js +439 -0
- package/dist/browser-client.js.map +1 -0
- package/dist/eip712.cjs +70 -0
- package/dist/eip712.cjs.map +1 -0
- package/dist/eip712.d.cts +181 -0
- package/dist/eip712.d.ts +181 -0
- package/dist/eip712.js +65 -0
- package/dist/eip712.js.map +1 -0
- package/dist/errors.cjs +102 -0
- package/dist/errors.cjs.map +1 -0
- package/dist/errors.d.cts +56 -0
- package/dist/errors.d.ts +56 -0
- package/dist/errors.js +90 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.cjs +831 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +193 -0
- package/dist/index.d.ts +193 -0
- package/dist/index.js +806 -0
- package/dist/index.js.map +1 -0
- package/dist/webhooks.cjs +54 -0
- package/dist/webhooks.cjs.map +1 -0
- package/dist/webhooks.d.cts +19 -0
- package/dist/webhooks.d.ts +19 -0
- package/dist/webhooks.js +13 -0
- package/dist/webhooks.js.map +1 -0
- package/package.json +94 -0
- package/src/browser-client.ts +97 -0
- package/src/client.ts +122 -0
- package/src/eip712/index.ts +25 -0
- package/src/eip712/permit.ts +74 -0
- package/src/eip712/receive-with-authorization.ts +88 -0
- package/src/eip712/types.ts +46 -0
- package/src/errors.ts +138 -0
- package/src/http/fetcher.ts +226 -0
- package/src/http/headers.ts +32 -0
- package/src/idempotency.ts +20 -0
- package/src/index.ts +77 -0
- package/src/pagination.ts +48 -0
- package/src/resources/agents.ts +64 -0
- package/src/resources/api-keys.ts +29 -0
- package/src/resources/base-resource.ts +116 -0
- package/src/resources/checkout.ts +32 -0
- package/src/resources/customers.ts +25 -0
- package/src/resources/invoices.ts +31 -0
- package/src/resources/merchants.ts +26 -0
- package/src/resources/payment-sessions.ts +41 -0
- package/src/resources/refunds.ts +36 -0
- package/src/resources/storefronts.ts +62 -0
- package/src/resources/subscription-plans.ts +32 -0
- package/src/resources/subscriptions.ts +37 -0
- package/src/resources/tokens.ts +70 -0
- package/src/resources/transactions.ts +20 -0
- package/src/resources/webhook-deliveries.ts +29 -0
- package/src/resources/webhook-endpoints.ts +56 -0
- package/src/webhooks.ts +64 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EIP-712 typed-data builders.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions — no wallet dependency. The returned shape matches
|
|
5
|
+
* the EIP-712 standard and is accepted by every major wallet library:
|
|
6
|
+
*
|
|
7
|
+
* import { buildReceiveWithAuthorizationTypedData } from '@strimz/sdk/eip712'
|
|
8
|
+
*
|
|
9
|
+
* const typedData = buildReceiveWithAuthorizationTypedData({ ... })
|
|
10
|
+
* const signature = await walletClient.signTypedData(typedData)
|
|
11
|
+
*
|
|
12
|
+
* The builders are deliberately granular (one per primitive — EIP-3009,
|
|
13
|
+
* EIP-2612) so the SDK can support future schemes (e.g. x402 batched
|
|
14
|
+
* payments) by adding a sibling builder without touching existing
|
|
15
|
+
* call sites.
|
|
16
|
+
*/
|
|
17
|
+
export type { Eip712Domain, Eip712Field, Eip712Types, Eip712TypedData } from './types.js'
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
buildReceiveWithAuthorizationTypedData,
|
|
21
|
+
RECEIVE_WITH_AUTHORIZATION_TYPES,
|
|
22
|
+
type ReceiveWithAuthorizationParams,
|
|
23
|
+
} from './receive-with-authorization.js'
|
|
24
|
+
|
|
25
|
+
export { buildPermitTypedData, PERMIT_TYPES, type PermitParams } from './permit.js'
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Eip712TypedData } from './types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Inputs for the EIP-2612 Permit typed-data.
|
|
5
|
+
*
|
|
6
|
+
* - `chainId`, `token`, `tokenName`, `tokenVersion` form the EIP-712
|
|
7
|
+
* domain (same shape as the EIP-3009 builder).
|
|
8
|
+
* - `owner` is the token holder authorising the allowance — must match
|
|
9
|
+
* the signing wallet.
|
|
10
|
+
* - `spender` is the contract that will pull funds — for Strimz
|
|
11
|
+
* subscriptions this is the StrimzSubscriptions contract.
|
|
12
|
+
* - `value` is the allowance to grant in token base units.
|
|
13
|
+
* `type(uint256).max` produces an unlimited allowance and is the
|
|
14
|
+
* common case for subscription enrolment.
|
|
15
|
+
* - `nonce` is the owner's *current* `nonces(owner)` reading from the
|
|
16
|
+
* token contract. The SDK does not fetch this — the caller is
|
|
17
|
+
* expected to have just queried it. Race-free: the token rejects
|
|
18
|
+
* stale nonces, so a concurrent permit on the same owner just
|
|
19
|
+
* causes one to fail predictably.
|
|
20
|
+
* - `deadline` is the unix-second timestamp after which the permit
|
|
21
|
+
* expires. Strimz default for subscription enrolment is 24h —
|
|
22
|
+
* enough headroom for a slow merchant submit pipeline without
|
|
23
|
+
* leaving an indefinite signature window.
|
|
24
|
+
*/
|
|
25
|
+
export interface PermitParams {
|
|
26
|
+
chainId: number
|
|
27
|
+
token: `0x${string}`
|
|
28
|
+
tokenName: string
|
|
29
|
+
tokenVersion: string
|
|
30
|
+
owner: `0x${string}`
|
|
31
|
+
spender: `0x${string}`
|
|
32
|
+
value: bigint
|
|
33
|
+
nonce: bigint
|
|
34
|
+
deadline: bigint
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* EIP-712 type definition for `Permit`. Stable across every EIP-2612
|
|
39
|
+
* implementation (USDC, EURC, DAI v2, etc.).
|
|
40
|
+
*/
|
|
41
|
+
export const PERMIT_TYPES = {
|
|
42
|
+
Permit: [
|
|
43
|
+
{ name: 'owner', type: 'address' },
|
|
44
|
+
{ name: 'spender', type: 'address' },
|
|
45
|
+
{ name: 'value', type: 'uint256' },
|
|
46
|
+
{ name: 'nonce', type: 'uint256' },
|
|
47
|
+
{ name: 'deadline', type: 'uint256' },
|
|
48
|
+
],
|
|
49
|
+
} as const
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build the typed-data structure for an EIP-2612 `Permit` signature.
|
|
53
|
+
* Returns the canonical shape compatible with viem, ethers, Privy,
|
|
54
|
+
* and Reown.
|
|
55
|
+
*/
|
|
56
|
+
export function buildPermitTypedData(params: PermitParams): Eip712TypedData<'Permit'> {
|
|
57
|
+
return {
|
|
58
|
+
domain: {
|
|
59
|
+
name: params.tokenName,
|
|
60
|
+
version: params.tokenVersion,
|
|
61
|
+
chainId: params.chainId,
|
|
62
|
+
verifyingContract: params.token,
|
|
63
|
+
},
|
|
64
|
+
types: PERMIT_TYPES,
|
|
65
|
+
primaryType: 'Permit',
|
|
66
|
+
message: {
|
|
67
|
+
owner: params.owner,
|
|
68
|
+
spender: params.spender,
|
|
69
|
+
value: params.value,
|
|
70
|
+
nonce: params.nonce,
|
|
71
|
+
deadline: params.deadline,
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Eip712TypedData } from './types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Inputs for the EIP-3009 ReceiveWithAuthorization typed-data.
|
|
5
|
+
*
|
|
6
|
+
* - `chainId` is the EVM chain id (Arc Testnet = 5042002).
|
|
7
|
+
* - `token` is the ERC-20 contract whose `receiveWithAuthorization` is
|
|
8
|
+
* being invoked — usually USDC at `0x36...` on Arc.
|
|
9
|
+
* - `tokenName` and `tokenVersion` form the EIP-712 domain. Real USDC
|
|
10
|
+
* on most chains uses `name="USD Coin"` and `version="2"`; tests
|
|
11
|
+
* use whatever the mock token reports. Always read these from the
|
|
12
|
+
* token contract at runtime to avoid drift.
|
|
13
|
+
* - `from` is the payer; the wallet that produces the signature must
|
|
14
|
+
* match this address.
|
|
15
|
+
* - `to` is the recipient of the funds — for Strimz this is the
|
|
16
|
+
* StrimzPayments contract address (the contract pulls funds into
|
|
17
|
+
* itself, then forwards the fee + net split).
|
|
18
|
+
* - `value` is the gross amount in token base units.
|
|
19
|
+
* - `validAfter` / `validBefore` are unix-second timestamps bounding
|
|
20
|
+
* the signature's validity. Strimz's default checkout window is
|
|
21
|
+
* 5 minutes; integrators may widen this for usage-based billing.
|
|
22
|
+
* - `nonce` is a single-use 32-byte value. Generate with crypto.randomBytes
|
|
23
|
+
* (server) or crypto.getRandomValues (browser). The token contract
|
|
24
|
+
* tracks `(authorizer, nonce) -> used` and rejects replays.
|
|
25
|
+
*/
|
|
26
|
+
export interface ReceiveWithAuthorizationParams {
|
|
27
|
+
chainId: number
|
|
28
|
+
token: `0x${string}`
|
|
29
|
+
tokenName: string
|
|
30
|
+
tokenVersion: string
|
|
31
|
+
from: `0x${string}`
|
|
32
|
+
to: `0x${string}`
|
|
33
|
+
value: bigint
|
|
34
|
+
validAfter: bigint
|
|
35
|
+
validBefore: bigint
|
|
36
|
+
nonce: `0x${string}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* EIP-712 type definition for `ReceiveWithAuthorization`. Stable —
|
|
41
|
+
* shared by every EIP-3009 implementation (USDC, EURC, etc.).
|
|
42
|
+
*
|
|
43
|
+
* Exported so consumers can introspect or extend with sibling
|
|
44
|
+
* primitive types (e.g. for x402 batched signing schemes that wrap
|
|
45
|
+
* multiple authorizations in one signature).
|
|
46
|
+
*/
|
|
47
|
+
export const RECEIVE_WITH_AUTHORIZATION_TYPES = {
|
|
48
|
+
ReceiveWithAuthorization: [
|
|
49
|
+
{ name: 'from', type: 'address' },
|
|
50
|
+
{ name: 'to', type: 'address' },
|
|
51
|
+
{ name: 'value', type: 'uint256' },
|
|
52
|
+
{ name: 'validAfter', type: 'uint256' },
|
|
53
|
+
{ name: 'validBefore', type: 'uint256' },
|
|
54
|
+
{ name: 'nonce', type: 'bytes32' },
|
|
55
|
+
],
|
|
56
|
+
} as const
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build the typed-data structure for an EIP-3009
|
|
60
|
+
* `ReceiveWithAuthorization` signature. Returns the canonical shape
|
|
61
|
+
* compatible with viem, ethers, Privy, and Reown.
|
|
62
|
+
*
|
|
63
|
+
* Pass the result to `walletClient.signTypedData(typedData)` (viem)
|
|
64
|
+
* or `signer._signTypedData(domain, types, message)` (ethers) — the
|
|
65
|
+
* SDK does not bind to a specific signer.
|
|
66
|
+
*/
|
|
67
|
+
export function buildReceiveWithAuthorizationTypedData(
|
|
68
|
+
params: ReceiveWithAuthorizationParams,
|
|
69
|
+
): Eip712TypedData<'ReceiveWithAuthorization'> {
|
|
70
|
+
return {
|
|
71
|
+
domain: {
|
|
72
|
+
name: params.tokenName,
|
|
73
|
+
version: params.tokenVersion,
|
|
74
|
+
chainId: params.chainId,
|
|
75
|
+
verifyingContract: params.token,
|
|
76
|
+
},
|
|
77
|
+
types: RECEIVE_WITH_AUTHORIZATION_TYPES,
|
|
78
|
+
primaryType: 'ReceiveWithAuthorization',
|
|
79
|
+
message: {
|
|
80
|
+
from: params.from,
|
|
81
|
+
to: params.to,
|
|
82
|
+
value: params.value,
|
|
83
|
+
validAfter: params.validAfter,
|
|
84
|
+
validBefore: params.validBefore,
|
|
85
|
+
nonce: params.nonce,
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical EIP-712 typed-data shape.
|
|
3
|
+
*
|
|
4
|
+
* Matches the structure every major wallet library accepts —
|
|
5
|
+
* viem's `signTypedData`
|
|
6
|
+
* ethers' `signer._signTypedData`
|
|
7
|
+
* Privy's `signTypedData`
|
|
8
|
+
* Reown AppKit's typed-data RPC
|
|
9
|
+
* — so the SDK can produce typed-data without binding to a specific
|
|
10
|
+
* wallet client. Consumers pipe the result into whichever signer they
|
|
11
|
+
* already use.
|
|
12
|
+
*
|
|
13
|
+
* No viem/ethers dependency on purpose: the SDK stays minimal, and
|
|
14
|
+
* apps can choose their own signing stack.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface Eip712Domain {
|
|
18
|
+
name?: string
|
|
19
|
+
version?: string
|
|
20
|
+
chainId?: number
|
|
21
|
+
verifyingContract?: `0x${string}`
|
|
22
|
+
salt?: `0x${string}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Eip712Field {
|
|
26
|
+
name: string
|
|
27
|
+
type: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type Eip712Types = Record<string, readonly Eip712Field[]>
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* EIP-712 typed-data definition. `primaryType` names the struct that
|
|
34
|
+
* gets hashed at the top level (the `types` object usually contains
|
|
35
|
+
* additional referenced sub-structs).
|
|
36
|
+
*
|
|
37
|
+
* `bigint` is used for uint values; wallet libs handle the conversion
|
|
38
|
+
* to hex/decimal at signing time. If a consumer needs to JSON-serialise
|
|
39
|
+
* this object, convert bigints to strings at that boundary.
|
|
40
|
+
*/
|
|
41
|
+
export interface Eip712TypedData<TPrimary extends string = string> {
|
|
42
|
+
domain: Eip712Domain
|
|
43
|
+
types: Eip712Types
|
|
44
|
+
primaryType: TPrimary
|
|
45
|
+
message: Record<string, unknown>
|
|
46
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error hierarchy for the Strimz SDK.
|
|
3
|
+
*
|
|
4
|
+
* Every error thrown by the SDK extends `StrimzError`. The SDK never
|
|
5
|
+
* resolves to `{ ok: false }` — it throws. This matches the Stripe / GitHub
|
|
6
|
+
* SDK convention and keeps call sites linear.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type StrimzErrorCode =
|
|
10
|
+
| 'authentication_error'
|
|
11
|
+
| 'permission_denied'
|
|
12
|
+
| 'invalid_request'
|
|
13
|
+
| 'not_found'
|
|
14
|
+
| 'idempotency_error'
|
|
15
|
+
| 'rate_limited'
|
|
16
|
+
| 'mode_mismatch'
|
|
17
|
+
| 'merchant_not_found'
|
|
18
|
+
| 'merchant_inactive'
|
|
19
|
+
| 'token_not_whitelisted'
|
|
20
|
+
| 'compliance_blocked'
|
|
21
|
+
| 'session_expired'
|
|
22
|
+
| 'session_invalid_state'
|
|
23
|
+
| 'webhook_signature_invalid'
|
|
24
|
+
| 'api_error'
|
|
25
|
+
| 'network_error'
|
|
26
|
+
| 'timeout'
|
|
27
|
+
| 'parse_error'
|
|
28
|
+
| 'unknown'
|
|
29
|
+
|
|
30
|
+
export interface StrimzErrorBody {
|
|
31
|
+
code: StrimzErrorCode
|
|
32
|
+
message: string
|
|
33
|
+
param?: string
|
|
34
|
+
requestId?: string
|
|
35
|
+
details?: Record<string, unknown>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class StrimzError extends Error {
|
|
39
|
+
public readonly code: StrimzErrorCode
|
|
40
|
+
public readonly httpStatus: number | undefined
|
|
41
|
+
public readonly requestId: string | undefined
|
|
42
|
+
public readonly param: string | undefined
|
|
43
|
+
public readonly details: Record<string, unknown> | undefined
|
|
44
|
+
|
|
45
|
+
constructor(body: StrimzErrorBody, httpStatus?: number) {
|
|
46
|
+
super(body.message)
|
|
47
|
+
this.name = 'StrimzError'
|
|
48
|
+
this.code = body.code
|
|
49
|
+
this.httpStatus = httpStatus
|
|
50
|
+
this.requestId = body.requestId
|
|
51
|
+
this.param = body.param
|
|
52
|
+
this.details = body.details
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class StrimzAuthenticationError extends StrimzError {
|
|
57
|
+
constructor(body: StrimzErrorBody, httpStatus?: number) {
|
|
58
|
+
super(body, httpStatus)
|
|
59
|
+
this.name = 'StrimzAuthenticationError'
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class StrimzPermissionError extends StrimzError {
|
|
64
|
+
constructor(body: StrimzErrorBody, httpStatus?: number) {
|
|
65
|
+
super(body, httpStatus)
|
|
66
|
+
this.name = 'StrimzPermissionError'
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class StrimzValidationError extends StrimzError {
|
|
71
|
+
constructor(body: StrimzErrorBody, httpStatus?: number) {
|
|
72
|
+
super(body, httpStatus)
|
|
73
|
+
this.name = 'StrimzValidationError'
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class StrimzNotFoundError extends StrimzError {
|
|
78
|
+
constructor(body: StrimzErrorBody, httpStatus?: number) {
|
|
79
|
+
super(body, httpStatus)
|
|
80
|
+
this.name = 'StrimzNotFoundError'
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class StrimzIdempotencyError extends StrimzError {
|
|
85
|
+
constructor(body: StrimzErrorBody, httpStatus?: number) {
|
|
86
|
+
super(body, httpStatus)
|
|
87
|
+
this.name = 'StrimzIdempotencyError'
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class StrimzRateLimitError extends StrimzError {
|
|
92
|
+
public readonly retryAfterMs: number | undefined
|
|
93
|
+
constructor(body: StrimzErrorBody, httpStatus?: number, retryAfterMs?: number) {
|
|
94
|
+
super(body, httpStatus)
|
|
95
|
+
this.name = 'StrimzRateLimitError'
|
|
96
|
+
this.retryAfterMs = retryAfterMs
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class StrimzNetworkError extends StrimzError {
|
|
101
|
+
public override readonly cause: unknown
|
|
102
|
+
constructor(message: string, cause: unknown) {
|
|
103
|
+
super({ code: 'network_error', message })
|
|
104
|
+
this.name = 'StrimzNetworkError'
|
|
105
|
+
this.cause = cause
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export class StrimzTimeoutError extends StrimzError {
|
|
110
|
+
constructor(message: string) {
|
|
111
|
+
super({ code: 'timeout', message })
|
|
112
|
+
this.name = 'StrimzTimeoutError'
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class StrimzModeMismatchError extends StrimzError {
|
|
117
|
+
constructor(message: string) {
|
|
118
|
+
super({ code: 'mode_mismatch', message })
|
|
119
|
+
this.name = 'StrimzModeMismatchError'
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Map an HTTP status + body to the right StrimzError subclass. */
|
|
124
|
+
export function classifyError(
|
|
125
|
+
httpStatus: number,
|
|
126
|
+
body: StrimzErrorBody,
|
|
127
|
+
retryAfterMs?: number,
|
|
128
|
+
): StrimzError {
|
|
129
|
+
if (httpStatus === 401) return new StrimzAuthenticationError(body, httpStatus)
|
|
130
|
+
if (httpStatus === 403) return new StrimzPermissionError(body, httpStatus)
|
|
131
|
+
if (httpStatus === 404) return new StrimzNotFoundError(body, httpStatus)
|
|
132
|
+
if (httpStatus === 409 && body.code === 'idempotency_error') {
|
|
133
|
+
return new StrimzIdempotencyError(body, httpStatus)
|
|
134
|
+
}
|
|
135
|
+
if (httpStatus === 422) return new StrimzValidationError(body, httpStatus)
|
|
136
|
+
if (httpStatus === 429) return new StrimzRateLimitError(body, httpStatus, retryAfterMs)
|
|
137
|
+
return new StrimzError(body, httpStatus)
|
|
138
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch wrapper with retry, idempotency, and Strimz error parsing.
|
|
3
|
+
*
|
|
4
|
+
* Retries on:
|
|
5
|
+
* - Network failures (TypeError on fetch).
|
|
6
|
+
* - 5xx responses for idempotent methods (GET, POST with idempotency key).
|
|
7
|
+
* - 429 with Retry-After.
|
|
8
|
+
*
|
|
9
|
+
* Does NOT retry on:
|
|
10
|
+
* - 4xx other than 429 (caller's bug).
|
|
11
|
+
* - 5xx for non-idempotent calls without an idempotency key.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
classifyError,
|
|
16
|
+
StrimzError,
|
|
17
|
+
StrimzNetworkError,
|
|
18
|
+
StrimzTimeoutError,
|
|
19
|
+
type StrimzErrorBody,
|
|
20
|
+
} from '../errors.js'
|
|
21
|
+
import { HEADERS } from './headers.js'
|
|
22
|
+
|
|
23
|
+
export interface FetcherOptions {
|
|
24
|
+
baseUrl: string
|
|
25
|
+
baseHeaders: Record<string, string>
|
|
26
|
+
/** Default timeout per attempt, milliseconds. */
|
|
27
|
+
timeoutMs?: number
|
|
28
|
+
/** How many retries before giving up. */
|
|
29
|
+
maxRetries?: number
|
|
30
|
+
/** Initial backoff in ms; doubled with jitter on each retry. */
|
|
31
|
+
initialBackoffMs?: number
|
|
32
|
+
/** Optional logger. */
|
|
33
|
+
onRequest?: (info: { method: string; url: string; attempt: number }) => void
|
|
34
|
+
onResponse?: (info: {
|
|
35
|
+
method: string
|
|
36
|
+
url: string
|
|
37
|
+
status: number
|
|
38
|
+
ms: number
|
|
39
|
+
attempt: number
|
|
40
|
+
}) => void
|
|
41
|
+
/** Override the global fetch (for tests / Edge polyfills). */
|
|
42
|
+
fetch?: typeof globalThis.fetch
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface RequestSpec {
|
|
46
|
+
method: 'GET' | 'POST' | 'PATCH' | 'DELETE'
|
|
47
|
+
path: string
|
|
48
|
+
query?: Record<string, string | number | boolean | null | undefined>
|
|
49
|
+
body?: unknown
|
|
50
|
+
idempotencyKey?: string
|
|
51
|
+
/** Per-call timeout override. */
|
|
52
|
+
timeoutMs?: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const DEFAULT_TIMEOUT_MS = 30_000
|
|
56
|
+
const DEFAULT_MAX_RETRIES = 3
|
|
57
|
+
const DEFAULT_INITIAL_BACKOFF_MS = 250
|
|
58
|
+
|
|
59
|
+
export class Fetcher {
|
|
60
|
+
private readonly baseUrl: string
|
|
61
|
+
private readonly baseHeaders: Record<string, string>
|
|
62
|
+
private readonly timeoutMs: number
|
|
63
|
+
private readonly maxRetries: number
|
|
64
|
+
private readonly initialBackoffMs: number
|
|
65
|
+
private readonly onRequest: FetcherOptions['onRequest']
|
|
66
|
+
private readonly onResponse: FetcherOptions['onResponse']
|
|
67
|
+
private readonly fetchImpl: typeof globalThis.fetch
|
|
68
|
+
|
|
69
|
+
constructor(opts: FetcherOptions) {
|
|
70
|
+
this.baseUrl = opts.baseUrl.replace(/\/$/, '')
|
|
71
|
+
this.baseHeaders = opts.baseHeaders
|
|
72
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
73
|
+
this.maxRetries = opts.maxRetries ?? DEFAULT_MAX_RETRIES
|
|
74
|
+
this.initialBackoffMs = opts.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF_MS
|
|
75
|
+
this.onRequest = opts.onRequest
|
|
76
|
+
this.onResponse = opts.onResponse
|
|
77
|
+
this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async request<T>(spec: RequestSpec): Promise<T> {
|
|
81
|
+
const url = this.buildUrl(spec.path, spec.query)
|
|
82
|
+
const idempotent = isIdempotent(spec)
|
|
83
|
+
const headers: Record<string, string> = { ...this.baseHeaders }
|
|
84
|
+
if (spec.body != null) headers['Content-Type'] = 'application/json'
|
|
85
|
+
if (spec.idempotencyKey) headers[HEADERS.idempotencyKey] = spec.idempotencyKey
|
|
86
|
+
|
|
87
|
+
let attempt = 0
|
|
88
|
+
let lastErr: unknown
|
|
89
|
+
while (attempt <= this.maxRetries) {
|
|
90
|
+
try {
|
|
91
|
+
this.onRequest?.({ method: spec.method, url, attempt })
|
|
92
|
+
const start = Date.now()
|
|
93
|
+
const res = await this.fetchWithTimeout(
|
|
94
|
+
url,
|
|
95
|
+
{
|
|
96
|
+
method: spec.method,
|
|
97
|
+
headers,
|
|
98
|
+
body: spec.body == null ? undefined : JSON.stringify(spec.body),
|
|
99
|
+
},
|
|
100
|
+
spec.timeoutMs ?? this.timeoutMs,
|
|
101
|
+
)
|
|
102
|
+
const ms = Date.now() - start
|
|
103
|
+
this.onResponse?.({ method: spec.method, url, status: res.status, ms, attempt })
|
|
104
|
+
|
|
105
|
+
if (res.ok) {
|
|
106
|
+
if (res.status === 204) return undefined as T
|
|
107
|
+
return (await res.json()) as T
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const errorBody = await parseErrorBody(res)
|
|
111
|
+
const retryAfterMs = parseRetryAfter(res)
|
|
112
|
+
|
|
113
|
+
// 429 — back off and retry up to maxRetries.
|
|
114
|
+
if (res.status === 429 && attempt < this.maxRetries) {
|
|
115
|
+
await delay(retryAfterMs ?? this.backoffFor(attempt))
|
|
116
|
+
attempt++
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
// 5xx — retry only if idempotent.
|
|
120
|
+
if (res.status >= 500 && idempotent && attempt < this.maxRetries) {
|
|
121
|
+
await delay(this.backoffFor(attempt))
|
|
122
|
+
attempt++
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
throw classifyError(res.status, errorBody, retryAfterMs)
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (err instanceof StrimzError) throw err
|
|
129
|
+
lastErr = err
|
|
130
|
+
if (!isNetworkLike(err) || attempt >= this.maxRetries) {
|
|
131
|
+
if (err instanceof StrimzTimeoutError) throw err
|
|
132
|
+
throw new StrimzNetworkError(`Network error: ${describe(err)}`, err)
|
|
133
|
+
}
|
|
134
|
+
await delay(this.backoffFor(attempt))
|
|
135
|
+
attempt++
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
throw new StrimzNetworkError(`Network error after ${this.maxRetries} retries`, lastErr)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async fetchWithTimeout(
|
|
142
|
+
url: string,
|
|
143
|
+
init: RequestInit,
|
|
144
|
+
timeoutMs: number,
|
|
145
|
+
): Promise<Response> {
|
|
146
|
+
const controller = new AbortController()
|
|
147
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
148
|
+
try {
|
|
149
|
+
return await this.fetchImpl(url, { ...init, signal: controller.signal })
|
|
150
|
+
} catch (err) {
|
|
151
|
+
if (isAbortError(err)) {
|
|
152
|
+
throw new StrimzTimeoutError(`Request timed out after ${timeoutMs}ms`)
|
|
153
|
+
}
|
|
154
|
+
throw err
|
|
155
|
+
} finally {
|
|
156
|
+
clearTimeout(timer)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private buildUrl(path: string, query?: RequestSpec['query']): string {
|
|
161
|
+
const base = `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`
|
|
162
|
+
if (!query) return base
|
|
163
|
+
const usp = new URLSearchParams()
|
|
164
|
+
for (const [k, v] of Object.entries(query)) {
|
|
165
|
+
if (v == null) continue
|
|
166
|
+
usp.append(k, String(v))
|
|
167
|
+
}
|
|
168
|
+
const qs = usp.toString()
|
|
169
|
+
return qs ? `${base}?${qs}` : base
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private backoffFor(attempt: number): number {
|
|
173
|
+
const expo = this.initialBackoffMs * Math.pow(2, attempt)
|
|
174
|
+
const jitter = Math.random() * this.initialBackoffMs
|
|
175
|
+
return Math.min(expo + jitter, 8_000)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isIdempotent(spec: RequestSpec): boolean {
|
|
180
|
+
if (spec.method === 'GET' || spec.method === 'DELETE') return true
|
|
181
|
+
return spec.idempotencyKey != null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function parseErrorBody(res: Response): Promise<StrimzErrorBody> {
|
|
185
|
+
try {
|
|
186
|
+
const json = (await res.json()) as { error?: StrimzErrorBody } | StrimzErrorBody
|
|
187
|
+
if (typeof json === 'object' && json !== null && 'error' in json && json.error) {
|
|
188
|
+
return json.error
|
|
189
|
+
}
|
|
190
|
+
if (typeof json === 'object' && json !== null && 'code' in json && 'message' in json) {
|
|
191
|
+
return json as StrimzErrorBody
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// fall through
|
|
195
|
+
}
|
|
196
|
+
return { code: 'api_error', message: `HTTP ${res.status}` }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function parseRetryAfter(res: Response): number | undefined {
|
|
200
|
+
const v = res.headers.get('Retry-After')
|
|
201
|
+
if (!v) return undefined
|
|
202
|
+
const n = Number(v)
|
|
203
|
+
if (Number.isFinite(n)) return Math.max(0, n) * 1_000
|
|
204
|
+
const date = Date.parse(v)
|
|
205
|
+
if (Number.isFinite(date)) return Math.max(0, date - Date.now())
|
|
206
|
+
return undefined
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function delay(ms: number): Promise<void> {
|
|
210
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isAbortError(err: unknown): boolean {
|
|
214
|
+
return err instanceof Error && err.name === 'AbortError'
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isNetworkLike(err: unknown): boolean {
|
|
218
|
+
if (err instanceof TypeError) return true // fetch network failures
|
|
219
|
+
if (err instanceof Error && /fetch|network|econn/i.test(err.message)) return true
|
|
220
|
+
return false
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function describe(err: unknown): string {
|
|
224
|
+
if (err instanceof Error) return err.message
|
|
225
|
+
return String(err)
|
|
226
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strimz HTTP header conventions.
|
|
3
|
+
*
|
|
4
|
+
* `Authorization: Bearer <secret_key>` — primary auth.
|
|
5
|
+
* `X-Strimz-Idempotency-Key` — mutating calls.
|
|
6
|
+
* `X-Strimz-Sdk` — telemetry.
|
|
7
|
+
* `X-Strimz-Sdk-Version` — telemetry.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const HEADERS = {
|
|
11
|
+
authorization: 'Authorization',
|
|
12
|
+
idempotencyKey: 'X-Strimz-Idempotency-Key',
|
|
13
|
+
requestId: 'X-Strimz-Request-Id',
|
|
14
|
+
sdkName: 'X-Strimz-Sdk',
|
|
15
|
+
sdkVersion: 'X-Strimz-Sdk-Version',
|
|
16
|
+
sdkRuntime: 'X-Strimz-Sdk-Runtime',
|
|
17
|
+
} as const
|
|
18
|
+
|
|
19
|
+
export const SDK_NAME = '@strimz/sdk'
|
|
20
|
+
|
|
21
|
+
/** The SDK version. Updated by the build pipeline if a version is published. */
|
|
22
|
+
export const SDK_VERSION = '0.0.0'
|
|
23
|
+
|
|
24
|
+
export function buildBaseHeaders(apiKey: string, runtime: string): Record<string, string> {
|
|
25
|
+
return {
|
|
26
|
+
[HEADERS.authorization]: `Bearer ${apiKey}`,
|
|
27
|
+
[HEADERS.sdkName]: SDK_NAME,
|
|
28
|
+
[HEADERS.sdkVersion]: SDK_VERSION,
|
|
29
|
+
[HEADERS.sdkRuntime]: runtime,
|
|
30
|
+
Accept: 'application/json',
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { uuid } from '@strimz/shared-crypto'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate an idempotency key for a mutating SDK call.
|
|
5
|
+
* Stripe convention: a UUID-like value with a stable prefix the merchant
|
|
6
|
+
* can grep for in logs.
|
|
7
|
+
*/
|
|
8
|
+
export function generateIdempotencyKey(): string {
|
|
9
|
+
return `strimz_${uuid()}`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** RFC 4122 UUID-shaped, optionally with the strimz_ prefix. */
|
|
13
|
+
const IDEMPOTENCY_KEY_PATTERN =
|
|
14
|
+
/^(strimz_)?[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
|
15
|
+
|
|
16
|
+
export function isValidIdempotencyKey(key: string): boolean {
|
|
17
|
+
if (typeof key !== 'string') return false
|
|
18
|
+
if (key.length > 255) return false
|
|
19
|
+
return IDEMPOTENCY_KEY_PATTERN.test(key)
|
|
20
|
+
}
|