@chipmobilesdk/rn-payment 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.
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Public configuration and catalog types.
3
+ *
4
+ * Nothing here names Google Play Billing, `react-native-iap`, or any store
5
+ * concept (FR-002). Apps speak in **product keys** and **entitlement keys** they
6
+ * chose themselves; store product identifiers appear only inside catalog
7
+ * configuration, never in app logic (FR-003).
8
+ */
9
+
10
+ /** An app-chosen logical entitlement, e.g. `no_ads`. What business logic asks about. */
11
+ export type EntitlementKey = string;
12
+
13
+ /** An app-chosen logical product, e.g. `premium_yearly`. Never a store identifier. */
14
+ export type ProductKey = string;
15
+
16
+ /**
17
+ * How a product behaves once bought.
18
+ *
19
+ * `one_time_permanent` — bought once, kept forever.
20
+ * `one_time_consumable` — bought, used up by the app, buyable again (FR-043).
21
+ * `subscription` — recurring, with its own lifecycle (FR-048).
22
+ */
23
+ export type ProductType =
24
+ | 'one_time_permanent'
25
+ | 'one_time_consumable'
26
+ | 'subscription';
27
+
28
+ /** Store identifiers per platform. iOS is optional and additive (FR-007). */
29
+ export interface StoreProductIds {
30
+ android?: string;
31
+ ios?: string;
32
+ }
33
+
34
+ /** One product the app sells, and what it unlocks. */
35
+ export interface CatalogEntry {
36
+ productKey: ProductKey;
37
+ productType: ProductType;
38
+ storeProductIds: StoreProductIds;
39
+ /** Entitlement keys this product confers. Many-to-many is supported (FR-010). */
40
+ grants: EntitlementKey[];
41
+ }
42
+
43
+ /** Separately configurable, and the basis of the production-transaction guard. */
44
+ export type Environment = 'development' | 'test' | 'production';
45
+
46
+ /**
47
+ * Entitlement policy for a subscription whose renewal payment failed.
48
+ *
49
+ * Kept configurable because it is a business decision, not a technical constant
50
+ * (FR-049). See `entitlements/policy.ts` for the defaults and why.
51
+ */
52
+ export type LapsePolicy = 'grant' | 'withhold';
53
+
54
+ /** What to do when a configured validation hook cannot be reached (FR-072). */
55
+ export type ValidationUnavailablePolicy = 'grant_and_queue' | 'withhold';
56
+
57
+ /** Returned by every subscribe call. Idempotent and safe to call twice (FR-006). */
58
+ export type Unsubscribe = () => void;
59
+
60
+ export interface TimeoutConfig {
61
+ connectMs?: number;
62
+ productQueryMs?: number;
63
+ syncMs?: number;
64
+ /** Covers opening the store surface only. Time the user spends in it is not counted (FR-035). */
65
+ purchaseLaunchMs?: number;
66
+ finalizeAttemptMs?: number;
67
+ }
68
+
69
+ export interface RetryConfig {
70
+ reconnect?: BackoffConfig;
71
+ finalize?: BackoffConfig;
72
+ }
73
+
74
+ export interface BackoffConfig {
75
+ initialDelayMs?: number;
76
+ maxDelayMs?: number;
77
+ factor?: number;
78
+ /** Attempts per session. Finalize is resumed next session rather than abandoned (FR-045). */
79
+ maxAttempts?: number;
80
+ jitter?: boolean;
81
+ }
82
+
83
+ /** What the app's backend is asked to decide about (FR-069). */
84
+ export interface ValidationCandidate {
85
+ productKey: ProductKey;
86
+ productType: ProductType;
87
+ /** Opaque verification material for the backend. Never logged (FR-074). */
88
+ verificationPayload: string;
89
+ /** Ties this decision to one purchase cycle in the event stream (FR-076). */
90
+ correlationId: string;
91
+ }
92
+
93
+ export interface ValidationDecision {
94
+ approved: boolean;
95
+ /** Optional app-supplied reason, surfaced as a distinguishable outcome (FR-071). */
96
+ reason?: string;
97
+ }
98
+
99
+ export type ValidationHook = (
100
+ candidate: ValidationCandidate,
101
+ ) => Promise<ValidationDecision>;
102
+
103
+ /** Diagnostics sink. Deliberately not tied to any analytics vendor (FR-080). */
104
+ export interface LoggerAdapter {
105
+ debug?(message: string, meta?: Record<string, unknown>): void;
106
+ warn?(message: string, meta?: Record<string, unknown>): void;
107
+ error?(message: string, meta?: Record<string, unknown>): void;
108
+ }
109
+
110
+ export interface PaymentConfig {
111
+ catalog: CatalogEntry[];
112
+ /** Entitlement keys the app intends to query. Used for orphan detection (FR-011). */
113
+ entitlements: EntitlementKey[];
114
+ environment: Environment;
115
+ /**
116
+ * Whether the running binary is a production build.
117
+ *
118
+ * Supplied by the app because only the app knows how it was built. Drives the
119
+ * fail-closed guard: a non-production build declaring `production` must not
120
+ * proceed (FR-015).
121
+ */
122
+ isProductionBuild: boolean;
123
+ /**
124
+ * Global sell switch. When false, purchases report `disabled` but entitlements
125
+ * already held stay readable — turning off selling is not the same as taking
126
+ * away what someone bought (FR-012).
127
+ */
128
+ enabled?: boolean;
129
+ gracePolicy?: LapsePolicy;
130
+ holdPolicy?: LapsePolicy;
131
+ staleAfterMs?: number;
132
+ productCacheTtlMs?: number;
133
+ foregroundSyncMinIntervalMs?: number;
134
+ timeouts?: TimeoutConfig;
135
+ retry?: RetryConfig;
136
+ /**
137
+ * Opaque cache-partition key (FR-057a).
138
+ *
139
+ * Not a user identifier: the package never interprets it, transmits it, or
140
+ * attaches it to a transaction. Changing it makes the previous snapshot
141
+ * unreadable, so a second app-account user cannot inherit the first one's
142
+ * entitlements even if the app forgets to call `clearLocalData`.
143
+ */
144
+ scopeToken?: string;
145
+ validateTransaction?: ValidationHook;
146
+ onValidationUnavailable?: ValidationUnavailablePolicy;
147
+ logger?: LoggerAdapter;
148
+ }
149
+
150
+ /** Opaque handle for a transaction. Exposes no raw store record (FR-051). */
151
+ export interface TransactionRef {
152
+ readonly transactionId: string;
153
+ readonly productKey: ProductKey;
154
+ }
155
+
156
+ export interface PurchaseOptions {
157
+ /**
158
+ * The app's obfuscated account identifier, passed straight through to the
159
+ * store for fraud prevention. Never generated, inferred, or persisted by the
160
+ * package (FR-034), and distinct from `scopeToken`.
161
+ */
162
+ obfuscatedAccountId?: string;
163
+ /** Which pricing option to buy, for a subscription with several offers. */
164
+ offerKey?: string;
165
+ }
166
+
167
+ /**
168
+ * Every way a purchase can end (FR-005).
169
+ *
170
+ * A discriminated union rather than a boolean plus an error, because the app has
171
+ * to tell these apart: `user_cancelled` is a normal outcome to stay quiet about,
172
+ * `pending` means wait, `already_owned` means re-sync, and `store_error` means
173
+ * show something. Collapsing them is how apps end up reporting a cancellation as
174
+ * a failure.
175
+ */
176
+ export type PurchaseResult =
177
+ | { kind: 'success'; productKey: ProductKey; transaction: TransactionRef }
178
+ /** Money has not arrived. No entitlement is granted (FR-037). */
179
+ | { kind: 'pending'; productKey: ProductKey; transaction: TransactionRef }
180
+ /** Normal, not an error. Must never be recorded as a technical failure (FR-032). */
181
+ | { kind: 'user_cancelled'; productKey: ProductKey }
182
+ /** Triggers a re-sync: this is how a reinstall repairs itself (FR-033). */
183
+ | { kind: 'already_owned'; productKey: ProductKey }
184
+ | { kind: 'ineligible'; productKey: ProductKey }
185
+ | { kind: 'unknown_product'; productKey: ProductKey }
186
+ | { kind: 'product_unavailable_in_region'; productKey: ProductKey }
187
+ /** Global sell switch is off (FR-012). */
188
+ | { kind: 'disabled' }
189
+ | { kind: 'not_ready'; status: PaymentStatusKind }
190
+ | { kind: 'unsupported' }
191
+ /** A second purchase while one is open. Refused, never queued (FR-031). */
192
+ | { kind: 'purchase_in_progress' }
193
+ /** Refused because this build may not create a production transaction (FR-014). */
194
+ | { kind: 'environment_blocked'; productKey: ProductKey; environment: Environment }
195
+ /** The app's backend said no (FR-071). Distinguishable from a network failure. */
196
+ | { kind: 'validation_rejected'; productKey: ProductKey; reason?: string }
197
+ | { kind: 'timeout'; productKey: ProductKey }
198
+ | { kind: 'network_error'; productKey: ProductKey }
199
+ | { kind: 'store_error'; productKey: ProductKey; code: string };
200
+
201
+ /** Outcome of finalizing or consuming a transaction (FR-044). */
202
+ export type FinalizeResult =
203
+ | { kind: 'finalized' }
204
+ /** Already done. Idempotent by design, so this is a success, not an error. */
205
+ | { kind: 'already_finalized' }
206
+ | { kind: 'unknown_transaction' }
207
+ /** Queued for bounded retry; the condition stays observable (FR-045). */
208
+ | { kind: 'retry_scheduled'; attempts: number }
209
+ | { kind: 'failed'; code: string };
210
+
211
+ /** A paid transaction not yet settled with the store, surfaced so it is not swallowed. */
212
+ export interface UnfinalizedSummary {
213
+ productKey: ProductKey;
214
+ transactionId: string;
215
+ attempts: number;
216
+ purchasedAt: number;
217
+ }
218
+
219
+ /** Observable package status (FR-019). */
220
+ export type PaymentStatusKind =
221
+ | 'not_started'
222
+ | 'connecting'
223
+ | 'ready'
224
+ | 'disabled'
225
+ | 'unavailable'
226
+ | 'failed';
227
+
228
+ /** Why selling is impossible here, kept distinguishable so apps can tell users why (FR-021). */
229
+ export type UnavailableReason =
230
+ | 'no_billing_service'
231
+ | 'billing_service_outdated'
232
+ | 'platform_unsupported';
233
+
234
+ /**
235
+ * A purchase the package refused because it could have been a real charge
236
+ * outside production (FR-014).
237
+ *
238
+ * Exposed in status rather than only logged so an automated test can assert the
239
+ * block happened, which is the difference between a guarantee and a hope.
240
+ */
241
+ export interface BlockedOperation {
242
+ productKey: ProductKey;
243
+ environment: Environment;
244
+ at: number;
245
+ }
246
+
247
+ export interface PaymentStatus {
248
+ kind: PaymentStatusKind;
249
+ unavailableReason?: UnavailableReason;
250
+ /** Whether retrying initialization could plausibly help (FR-024). */
251
+ retryable?: boolean;
252
+ /** Reconnect attempts made in this session, so a retry storm is visible (FR-020). */
253
+ reconnectAttempts?: number;
254
+ blockedOperations: BlockedOperation[];
255
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Catalog validation (FR-011).
3
+ *
4
+ * Runs at `configure`, before any connection is opened, so a bad catalog fails
5
+ * at configuration time rather than at the moment a user taps Buy. Every failure
6
+ * **names the offending entry** — "invalid catalog" alone is useless in an app
7
+ * with thirty products.
8
+ *
9
+ * All problems are collected rather than thrown one at a time, so a developer
10
+ * fixes the catalog in one pass instead of rerunning to find the next fault.
11
+ */
12
+
13
+ import { PaymentConfigError } from '../diagnostics/errors';
14
+ import {
15
+ resolveForegroundSyncMinIntervalMs,
16
+ resolveProductCacheTtlMs,
17
+ resolveRetry,
18
+ resolveStaleAfterMs,
19
+ resolveTimeouts,
20
+ type ResolvedRetry,
21
+ type ResolvedTimeouts,
22
+ } from '../core/timers';
23
+ import type {
24
+ CatalogEntry,
25
+ EntitlementKey,
26
+ LapsePolicy,
27
+ PaymentConfig,
28
+ ProductKey,
29
+ ProductType,
30
+ ValidationUnavailablePolicy,
31
+ } from './types';
32
+
33
+ const PRODUCT_TYPES: readonly ProductType[] = [
34
+ 'one_time_permanent',
35
+ 'one_time_consumable',
36
+ 'subscription',
37
+ ];
38
+
39
+ /** The platform whose store identifiers a catalog must supply. */
40
+ export type TargetPlatform = 'android' | 'ios';
41
+
42
+ /**
43
+ * A validated, indexed catalog plus every resolved policy value.
44
+ *
45
+ * Produced once so the hot paths never re-derive it, and so `getEntitlement` can
46
+ * stay a synchronous in-memory read (PERF-001).
47
+ */
48
+ export interface ResolvedConfig {
49
+ readonly entries: ReadonlyMap<ProductKey, CatalogEntry>;
50
+ /** productKey → entitlement keys it grants. */
51
+ readonly grants: ReadonlyMap<ProductKey, readonly EntitlementKey[]>;
52
+ /** entitlementKey → product keys that can grant it (FR-051, FR-052). */
53
+ readonly sources: ReadonlyMap<EntitlementKey, readonly ProductKey[]>;
54
+ /** storeProductId → productKey, for translating store callbacks back (FR-003). */
55
+ readonly byStoreId: ReadonlyMap<string, ProductKey>;
56
+ readonly entitlementKeys: readonly EntitlementKey[];
57
+ readonly platform: TargetPlatform;
58
+ readonly enabled: boolean;
59
+ readonly gracePolicy: LapsePolicy;
60
+ readonly holdPolicy: LapsePolicy;
61
+ readonly staleAfterMs: number;
62
+ readonly productCacheTtlMs: number;
63
+ readonly foregroundSyncMinIntervalMs: number;
64
+ readonly timeouts: ResolvedTimeouts;
65
+ readonly retry: ResolvedRetry;
66
+ readonly onValidationUnavailable: ValidationUnavailablePolicy;
67
+ }
68
+
69
+ export function validateConfig(
70
+ config: PaymentConfig,
71
+ platform: TargetPlatform = 'android',
72
+ ): ResolvedConfig {
73
+ const problems: string[] = [];
74
+ const offenders: string[] = [];
75
+
76
+ const entries = new Map<ProductKey, CatalogEntry>();
77
+ const grants = new Map<ProductKey, readonly EntitlementKey[]>();
78
+ const sources = new Map<EntitlementKey, ProductKey[]>();
79
+ const byStoreId = new Map<string, ProductKey>();
80
+
81
+ if (config.catalog.length === 0) {
82
+ problems.push('catalog is empty');
83
+ offenders.push('catalog');
84
+ }
85
+
86
+ for (const entry of config.catalog) {
87
+ const key = entry.productKey;
88
+
89
+ if (!key || key.trim() === '') {
90
+ problems.push('a catalog entry has an empty productKey');
91
+ offenders.push('(empty productKey)');
92
+ continue;
93
+ }
94
+
95
+ if (entries.has(key)) {
96
+ problems.push(`duplicate productKey "${key}"`);
97
+ offenders.push(key);
98
+ continue;
99
+ }
100
+
101
+ if (!PRODUCT_TYPES.includes(entry.productType)) {
102
+ problems.push(
103
+ `productKey "${key}" has invalid productType "${String(entry.productType)}"`,
104
+ );
105
+ offenders.push(key);
106
+ }
107
+
108
+ const storeId = entry.storeProductIds[platform];
109
+ if (!storeId || storeId.trim() === '') {
110
+ problems.push(
111
+ `productKey "${key}" is missing a store product id for platform "${platform}"`,
112
+ );
113
+ offenders.push(key);
114
+ } else {
115
+ const existing = byStoreId.get(storeId);
116
+ if (existing !== undefined) {
117
+ // Two product keys pointing at one store id would make a store callback
118
+ // ambiguous, so the wrong entitlement could be granted.
119
+ problems.push(
120
+ `store product id "${storeId}" is used by both "${existing}" and "${key}"`,
121
+ );
122
+ offenders.push(key);
123
+ } else {
124
+ byStoreId.set(storeId, key);
125
+ }
126
+ }
127
+
128
+ if (!Array.isArray(entry.grants) || entry.grants.length === 0) {
129
+ problems.push(`productKey "${key}" grants no entitlement`);
130
+ offenders.push(key);
131
+ }
132
+
133
+ entries.set(key, entry);
134
+ grants.set(key, [...(entry.grants ?? [])]);
135
+
136
+ for (const entitlement of entry.grants ?? []) {
137
+ const list = sources.get(entitlement);
138
+ if (list) {
139
+ list.push(key);
140
+ } else {
141
+ sources.set(entitlement, [key]);
142
+ }
143
+ }
144
+ }
145
+
146
+ // An entitlement the app intends to query but no product can grant is almost
147
+ // always a typo, and it would otherwise sit at `not_entitled` forever with no
148
+ // indication why.
149
+ for (const entitlement of config.entitlements) {
150
+ if (!sources.has(entitlement)) {
151
+ problems.push(
152
+ `entitlement "${entitlement}" is declared but no catalog product grants it`,
153
+ );
154
+ offenders.push(entitlement);
155
+ }
156
+ }
157
+
158
+ // A product granting something the app never declared is the mirror problem:
159
+ // harmless at runtime, but a strong signal of a rename half-applied.
160
+ const declared = new Set(config.entitlements);
161
+ for (const entitlement of sources.keys()) {
162
+ if (!declared.has(entitlement)) {
163
+ problems.push(
164
+ `entitlement "${entitlement}" is granted by a product but not declared in config.entitlements`,
165
+ );
166
+ offenders.push(entitlement);
167
+ }
168
+ }
169
+
170
+ if (problems.length > 0) {
171
+ throw new PaymentConfigError({
172
+ code: 'PAYMENT_INVALID_CATALOG',
173
+ message: `Payment catalog is invalid: ${problems.join('; ')}`,
174
+ operation: 'configure',
175
+ offenders: [...new Set(offenders)],
176
+ });
177
+ }
178
+
179
+ return {
180
+ entries,
181
+ grants,
182
+ sources: new Map([...sources].map(([k, v]) => [k, [...v]])),
183
+ byStoreId,
184
+ entitlementKeys: [...config.entitlements],
185
+ platform,
186
+ enabled: config.enabled ?? true,
187
+ gracePolicy: config.gracePolicy ?? 'grant',
188
+ holdPolicy: config.holdPolicy ?? 'withhold',
189
+ staleAfterMs: resolveStaleAfterMs(config.staleAfterMs),
190
+ productCacheTtlMs: resolveProductCacheTtlMs(config.productCacheTtlMs),
191
+ foregroundSyncMinIntervalMs: resolveForegroundSyncMinIntervalMs(
192
+ config.foregroundSyncMinIntervalMs,
193
+ ),
194
+ timeouts: resolveTimeouts(config.timeouts),
195
+ retry: resolveRetry(config.retry),
196
+ onValidationUnavailable: config.onValidationUnavailable ?? 'grant_and_queue',
197
+ };
198
+ }
199
+
200
+ /**
201
+ * The fail-closed environment guard (FR-015).
202
+ *
203
+ * Separate from catalog validation because it is not about the catalog's shape
204
+ * but about whether this binary is allowed to move real money at all. Checked
205
+ * before connecting, and it refuses rather than warns: a non-production build
206
+ * that declares `production` is one tap away from charging a real card, and
207
+ * continuing silently is exactly the failure this package exists to prevent.
208
+ */
209
+ export function assertEnvironmentSafe(config: PaymentConfig): void {
210
+ if (config.environment === 'production' && !config.isProductionBuild) {
211
+ throw new PaymentConfigError({
212
+ code: 'PAYMENT_PRODUCTION_CONFIG_IN_NON_PRODUCTION_BUILD',
213
+ message:
214
+ 'environment is "production" but isProductionBuild is false. Refusing to ' +
215
+ 'initialize: a non-production build must not be able to create a real ' +
216
+ 'transaction.',
217
+ operation: 'configure',
218
+ offenders: ['environment'],
219
+ });
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Whether this configuration may hand a real purchase to a real store (FR-014).
225
+ *
226
+ * False outside production regardless of what else is configured. The engine
227
+ * records each refusal in observable status so a test can assert the block
228
+ * happened — the difference between a guarantee and a hope.
229
+ */
230
+ export function mayCreateRealTransaction(config: PaymentConfig): boolean {
231
+ return config.environment === 'production' && config.isProductionBuild;
232
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Concurrency primitives.
3
+ *
4
+ * These are what make three of the specification's guarantees structurally true
5
+ * rather than hoped for: concurrent initialization opens exactly one connection
6
+ * (FR-018), concurrent syncs make exactly one store request (FR-063), and a
7
+ * second purchase while one is open is refused rather than queued (FR-031).
8
+ *
9
+ * The distinction between the two primitives matters. `singleFlight` **joins**
10
+ * concurrent callers onto one operation — correct for init and sync, where every
11
+ * caller wants the same answer. `exclusive` **refuses** the second caller —
12
+ * correct for purchase, where joining would silently give two callers one
13
+ * transaction, and queueing would open a store surface the user no longer
14
+ * expects.
15
+ */
16
+
17
+ /**
18
+ * Joins concurrent callers onto one in-flight operation.
19
+ *
20
+ * Callers arriving during the flight receive the same promise. Once it settles
21
+ * the slot clears, so a later call starts fresh — this is de-duplication, not
22
+ * caching.
23
+ */
24
+ export function createSingleFlight<T>(): {
25
+ run(operation: () => Promise<T>): Promise<T>;
26
+ inFlight(): boolean;
27
+ } {
28
+ let current: Promise<T> | undefined;
29
+
30
+ return {
31
+ run(operation: () => Promise<T>): Promise<T> {
32
+ if (current) {
33
+ return current;
34
+ }
35
+ const flight = operation().finally(() => {
36
+ if (current === flight) {
37
+ current = undefined;
38
+ }
39
+ });
40
+ current = flight;
41
+ return flight;
42
+ },
43
+ inFlight: () => current !== undefined,
44
+ };
45
+ }
46
+
47
+ /** Distinguishes "refused because busy" from any other failure, without throwing. */
48
+ export type ExclusiveOutcome<T> =
49
+ | { acquired: true; value: T }
50
+ | { acquired: false };
51
+
52
+ /**
53
+ * Admits one operation at a time and refuses the rest.
54
+ *
55
+ * Refusal is returned, not thrown, because for a purchase it is a normal
56
+ * outcome the app reports to the user — not an error to be caught.
57
+ */
58
+ export function createExclusive(): {
59
+ run<T>(operation: () => Promise<T>): Promise<ExclusiveOutcome<T>>;
60
+ busy(): boolean;
61
+ } {
62
+ let held = false;
63
+
64
+ return {
65
+ async run<T>(operation: () => Promise<T>): Promise<ExclusiveOutcome<T>> {
66
+ if (held) {
67
+ return { acquired: false };
68
+ }
69
+ held = true;
70
+ try {
71
+ return { acquired: true, value: await operation() };
72
+ } finally {
73
+ held = false;
74
+ }
75
+ },
76
+ busy: () => held,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Rate limiter for automatic foreground sync (PERF-002).
82
+ *
83
+ * Rejects rather than defers: a skipped automatic sync costs nothing because the
84
+ * next foreground event will try again, whereas a queue of deferred syncs would
85
+ * fire a burst the moment the interval elapsed — the exact storm this prevents.
86
+ */
87
+ export function createRateLimiter(
88
+ minIntervalMs: number,
89
+ now: () => number = Date.now,
90
+ ): { tryAcquire(): boolean; reset(): void } {
91
+ let lastAt: number | undefined;
92
+
93
+ return {
94
+ tryAcquire(): boolean {
95
+ const at = now();
96
+ if (lastAt !== undefined && at - lastAt < minIntervalMs) {
97
+ return false;
98
+ }
99
+ lastAt = at;
100
+ return true;
101
+ },
102
+ reset(): void {
103
+ lastAt = undefined;
104
+ },
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Rejects with a `PAYMENT_TIMEOUT`-shaped error after `ms`.
110
+ *
111
+ * Always clears its timer, including on the success path: a package that leaks a
112
+ * timer per operation fails the cleanup guarantee (FR-082, PERF-002).
113
+ */
114
+ export function withTimeout<T>(
115
+ promise: Promise<T>,
116
+ ms: number,
117
+ onTimeout: () => Error,
118
+ ): Promise<T> {
119
+ let timer: ReturnType<typeof setTimeout> | undefined;
120
+ const timeout = new Promise<never>((_, reject) => {
121
+ timer = setTimeout(() => reject(onTimeout()), ms);
122
+ });
123
+ return Promise.race([promise, timeout]).finally(() => {
124
+ if (timer !== undefined) {
125
+ clearTimeout(timer);
126
+ }
127
+ }) as Promise<T>;
128
+ }