Skip to content

Repository files navigation

@perryts/storekit

StoreKit 2 in-app purchase bindings (iOS / macOS) for Perry. For Android, use @perryts/play-billing.

Platforms

TargetImplementation
iOS 16+Native — Swift bridge over StoreKit 2 (Product.products(for:), etc.).
macOS 13+Native — same Swift bridge.
AndroidStub. Use @perryts/play-billing for Google Play Billing.
Linux / WindowsStub — every call resolves with a "not available" JSON payload.

Build requirements

No Swift toolchain, Xcode, or macOS host is required to build this package — for any target, including iOS:

  • Linux / Windows / Android targets — pure Rust. crate-stub (and crate-ios, which compiles the same stub fallback for non-Apple targets) builds with nothing but a Rust toolchain.
  • iOS / macOS targets on a Maccrate-ios/build.rs detects swiftc via xcrun and compiles swift/storekit_bridge.swift from source, as before.
  • iOS / macOS targets on a non-Mac host — the npm package ships prebuilt Swift bridge archives under crate-ios/prebuilt/<rust-triple>/ (built on macOS CI at publish time from the same Swift source). When build.rs finds no swiftc, it links the prebuilt archive for the target triple instead. The Rust side is pure Rust (perry-ffidashmap, once_cell), so cargo build --target aarch64-apple-ios needs only rustup target add aarch64-apple-ios — no Apple SDK, no C compiler. Perry's Linux builders then link the resulting archive into the app with ld64.lld and an Apple sysroot (PERRY_IOS_SYSROOT).

Set PERRY_STOREKIT_BRIDGE=source or =prebuilt to force either path. When working from a git checkout (which does not contain the prebuilt archives) on a non-Mac host, run scripts/build-bridges.sh once on a Mac and copy crate-ios/prebuilt/ over — or consume the npm package, which includes them.

Installation

npm install @perryts/storekit

The package targets perry-ffi ABI v0.5 (perry.nativeLibrary.abiVersion: "0.5" in package.json). Perry validates compatibility at build time.

Quick start

import{js_storekit_start_listener,js_storekit_load_products,js_storekit_purchase,js_storekit_has_subscription,js_storekit_get_jws,js_storekit_restore,}from"@perryts/storekit";// Boot the Transaction.updates listener once at launch — handles// Ask-to-Buy approvals, family-shared entitlements, auto-renew, etc.js_storekit_start_listener();// Load products you have configured in App Store Connect.constproductsJson=awaitjs_storekit_load_products("com.example.pro_monthly,com.example.pro_annual",);constproducts=JSON.parse(productsJson);// Drive the purchase sheet. The product must have been loaded first —// StoreKit 2 needs the in-memory `Product` value to call `purchase()`.constpurchaseJson=awaitjs_storekit_purchase("com.example.pro_monthly");constpurchase=JSON.parse(purchaseJson);if(purchase.success){// purchase.jws → server-side receipt validation// purchase.transactionId, purchase.purchaseDate → audit log}// Check entitlements at any time.constsubJson=awaitjs_storekit_has_subscription();const{ hasSubscription }=JSON.parse(subJson);

Typed wrapper (recommended)

The native FFI returns JSON strings so the cross-language contract stays simple. In your app, wrap the calls with the types this package re-exports:

import{js_storekit_load_products,js_storekit_purchase,js_storekit_has_subscription,js_storekit_get_jws,js_storekit_restore,typeProduct,typePurchaseResult,typeHasSubscriptionResult,typeJwsResult,typeRestoreResult,}from"@perryts/storekit";exportasyncfunctionloadProducts(ids: string[]): Promise<Product[]>{constjson=awaitjs_storekit_load_products(ids.join(","));constparsed=JSON.parse(json);if(parsed&&typeofparsed==="object"&&"error"inparsed){thrownewError(parsed.errorasstring);}returnparsedasProduct[];}exportasyncfunctionpurchase(productId: string): Promise<PurchaseResult>{constjson=awaitjs_storekit_purchase(productId);returnJSON.parse(json)asPurchaseResult;}exportasyncfunctionhasSubscription(): Promise<boolean>{constjson=awaitjs_storekit_has_subscription();return(JSON.parse(json)asHasSubscriptionResult).hasSubscription;}exportasyncfunctiongetJWS(): Promise<string|null>{constjson=awaitjs_storekit_get_jws();return(JSON.parse(json)asJwsResult).jws;}exportasyncfunctionrestorePurchases(): Promise<RestoreResult>{constjson=awaitjs_storekit_restore();returnJSON.parse(json)asRestoreResult;}

The Product and PurchaseResult shapes match the sketch in issue #537, with one practical addition: PurchaseResult.jws carries the App Store-issued JWS, which is what Apple's App Store Server API expects for server-side validation. (The legacy base64 receipt is no longer the recommended StoreKit 2 path.)

API reference

js_storekit_start_listener(): void

Start Transaction.updates in a detached Task. Verified transactions are automatically finish()-ed. Call exactly once at app launch — calling again cancels the previous listener.

js_storekit_load_products(commaSeparatedIds: string): Promise<string>

Resolves with a JSON array of Product objects. On failure: {"error": "..."}.

Loaded products are cached in a Swift actor so js_storekit_purchase can look them up by ID.

js_storekit_purchase(productId: string): Promise<string>

Resolves with a JSON PurchaseResult. Possible shapes:

{ "success": true, "jws": "eyJ…", "productId": "", "transactionId": "", "purchaseDate": "2026-05-07T10:23:11.123Z", "cancelled": false }
{ "success": false, "cancelled": true }
{ "success": false, "pending": true }
{ "success": false, "error": "" }

js_storekit_has_subscription(): Promise<string>

Resolves with {"hasSubscription": boolean}. True iff at least one of Transaction.currentEntitlements is verified and has no revocationDate.

js_storekit_get_jws(): Promise<string>

Resolves with {"jws": "…"} (most recent verified entitlement) or {"jws": null} (no active entitlement). Hand the JWS to your server, validate against Apple, then trust it.

js_storekit_restore(): Promise<string>

Calls AppStore.sync(). Resolves with {"success": true} or {"error": "…", "success": false}. Apple recommends only invoking this from a user-tapped "Restore Purchases" button.

How it's wired

TypeScript Rust (perry-ffi 0.5) Swift (StoreKit 2)
------------------- ---------------------- ----------------------
js_storekit_purchase → #[no_mangle] extern "C" → @_cdecl bridge fn
fn js_storekit_purchase runs Task { … }
returns *mut Promise calls back with JSON
←─── promise.resolve_string(json) ←──────────
  • crate-ios/ — Apple-platform crate. Depends on perry-ffi = "0.5" (tracked at git+https://github.com/PerryTS/perry). When targeting iOS/macOS, its build.rs compiles swift/storekit_bridge.swift to a static lib and links it — or, on hosts without swiftc (Linux builders), links the prebuilt bridge archive shipped under crate-ios/prebuilt/<rust-triple>/; package.json lists StoreKit and Foundation so perry's link step adds -framework. For any other target, build.rs is a no-op and the crate compiles the same stub fallback as crate-stub, so it builds on machines without Swift or Xcode.
  • crate-stub/ — non-Apple crate. Same exported js_storekit_* symbol set, but every call resolves immediately with a "not available on this platform" payload so calling code can fall back to a Stripe/web flow without #ifdef-style platform checks.
  • package.json :: perry.nativeLibrary — declares abiVersion: "0.5", the FFI symbol list, and per-target crate / lib / frameworks.

Server-side validation

This binding does not validate JWS receipts itself — that's plain HTTPS against Apple's App Store Server API. A typical flow:

  1. Client: js_storekit_purchase("…") → JWS.
  2. Client → your server: POST /verify-storekit { jws }.
  3. Server: validate signature, check transactionId, mark entitlement.
  4. Server → client: confirmation.

Periodically poll js_storekit_has_subscription() (or react to Transaction.updates) to keep the local cache fresh.

License

MIT

About

StoreKit 2 in-app purchase bindings for Perry apps (iOS/macOS)

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages