@solvapay/next 1.0.0-preview.7
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 +71 -0
- package/dist/index.cjs +242 -0
- package/dist/index.d.cts +129 -0
- package/dist/index.d.ts +129 -0
- package/dist/index.js +204 -0
- package/package.json +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# @solvapay/next
|
|
2
|
+
|
|
3
|
+
Next.js-specific utilities and helpers for SolvaPay SDK.
|
|
4
|
+
|
|
5
|
+
This package provides framework-specific helpers for Next.js API routes with built-in optimizations like request deduplication and caching.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @solvapay/next @solvapay/server next
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Check Subscription
|
|
16
|
+
|
|
17
|
+
The `checkSubscription` helper provides a simple way to check user subscription status in Next.js API routes with built-in request deduplication:
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
21
|
+
import { checkSubscription } from '@solvapay/next';
|
|
22
|
+
|
|
23
|
+
export async function GET(request: NextRequest) {
|
|
24
|
+
const result = await checkSubscription(request);
|
|
25
|
+
|
|
26
|
+
// If result is a NextResponse, it's an error response - return it
|
|
27
|
+
if (result instanceof NextResponse) {
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Otherwise, return the subscription data
|
|
32
|
+
return NextResponse.json(result);
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Features
|
|
37
|
+
|
|
38
|
+
- **Automatic Deduplication**: Prevents duplicate API calls by deduplicating concurrent requests
|
|
39
|
+
- **Caching**: Caches results for 2 seconds to prevent duplicate sequential requests
|
|
40
|
+
- **Automatic Cleanup**: Expired cache entries are automatically cleaned up
|
|
41
|
+
- **Memory Safe**: Maximum cache size limits prevent memory issues
|
|
42
|
+
|
|
43
|
+
### Cache Management
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
import {
|
|
47
|
+
clearSubscriptionCache,
|
|
48
|
+
clearAllSubscriptionCache,
|
|
49
|
+
getSubscriptionCacheStats
|
|
50
|
+
} from '@solvapay/next';
|
|
51
|
+
|
|
52
|
+
// Clear cache for a specific user
|
|
53
|
+
clearSubscriptionCache(userId);
|
|
54
|
+
|
|
55
|
+
// Clear all cache entries
|
|
56
|
+
clearAllSubscriptionCache();
|
|
57
|
+
|
|
58
|
+
// Get cache statistics
|
|
59
|
+
const stats = getSubscriptionCacheStats();
|
|
60
|
+
console.log(`In-flight: ${stats.inFlight}, Cached: ${stats.cached}`);
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Requirements
|
|
64
|
+
|
|
65
|
+
- Next.js >= 13.0.0
|
|
66
|
+
- Node.js >= 18.17
|
|
67
|
+
|
|
68
|
+
## Why a Separate Package?
|
|
69
|
+
|
|
70
|
+
This package is separate from `@solvapay/server` to keep the server package framework-agnostic. Users who use Express, Fastify, or other frameworks don't need Next.js as a dependency.
|
|
71
|
+
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
checkSubscription: () => checkSubscription,
|
|
34
|
+
clearAllSubscriptionCache: () => clearAllSubscriptionCache,
|
|
35
|
+
clearSubscriptionCache: () => clearSubscriptionCache,
|
|
36
|
+
getSubscriptionCacheStats: () => getSubscriptionCacheStats
|
|
37
|
+
});
|
|
38
|
+
module.exports = __toCommonJS(index_exports);
|
|
39
|
+
var import_server = require("next/server");
|
|
40
|
+
var import_server2 = require("@solvapay/server");
|
|
41
|
+
var import_core = require("@solvapay/core");
|
|
42
|
+
function createRequestDeduplicator(options = {}) {
|
|
43
|
+
const {
|
|
44
|
+
cacheTTL = 2e3,
|
|
45
|
+
maxCacheSize = 1e3,
|
|
46
|
+
cacheErrors = true
|
|
47
|
+
} = options;
|
|
48
|
+
const inFlightRequests = /* @__PURE__ */ new Map();
|
|
49
|
+
const resultCache = /* @__PURE__ */ new Map();
|
|
50
|
+
const cacheInvalidatedAt = /* @__PURE__ */ new Map();
|
|
51
|
+
let cleanupInterval = null;
|
|
52
|
+
if (cacheTTL > 0) {
|
|
53
|
+
cleanupInterval = setInterval(() => {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
const entriesToDelete = [];
|
|
56
|
+
for (const [key, cached] of resultCache.entries()) {
|
|
57
|
+
if (now - cached.timestamp >= cacheTTL) {
|
|
58
|
+
entriesToDelete.push(key);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const key of entriesToDelete) {
|
|
62
|
+
resultCache.delete(key);
|
|
63
|
+
cacheInvalidatedAt.delete(key);
|
|
64
|
+
}
|
|
65
|
+
if (resultCache.size > maxCacheSize) {
|
|
66
|
+
const sortedEntries = Array.from(resultCache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp);
|
|
67
|
+
const toRemove = sortedEntries.slice(0, resultCache.size - maxCacheSize);
|
|
68
|
+
for (const [key] of toRemove) {
|
|
69
|
+
resultCache.delete(key);
|
|
70
|
+
cacheInvalidatedAt.delete(key);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}, Math.min(cacheTTL, 1e3));
|
|
74
|
+
}
|
|
75
|
+
const deduplicate = async (key, fn) => {
|
|
76
|
+
if (cacheTTL > 0) {
|
|
77
|
+
const cached = resultCache.get(key);
|
|
78
|
+
if (cached && Date.now() - cached.timestamp < cacheTTL) {
|
|
79
|
+
return cached.data;
|
|
80
|
+
} else if (cached) {
|
|
81
|
+
resultCache.delete(key);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
let requestPromise = inFlightRequests.get(key);
|
|
85
|
+
if (!requestPromise) {
|
|
86
|
+
const requestStartTime = Date.now();
|
|
87
|
+
requestPromise = (async () => {
|
|
88
|
+
try {
|
|
89
|
+
const result = await fn();
|
|
90
|
+
const invalidatedAt = cacheInvalidatedAt.get(key);
|
|
91
|
+
const shouldCache = !invalidatedAt || invalidatedAt < requestStartTime;
|
|
92
|
+
if (cacheTTL > 0 && shouldCache) {
|
|
93
|
+
resultCache.set(key, {
|
|
94
|
+
data: result,
|
|
95
|
+
timestamp: Date.now()
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return result;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
const invalidatedAt = cacheInvalidatedAt.get(key);
|
|
101
|
+
const shouldCache = !invalidatedAt || invalidatedAt < requestStartTime;
|
|
102
|
+
if (cacheTTL > 0 && cacheErrors && shouldCache) {
|
|
103
|
+
resultCache.set(key, {
|
|
104
|
+
data: error,
|
|
105
|
+
timestamp: Date.now()
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
} finally {
|
|
110
|
+
inFlightRequests.delete(key);
|
|
111
|
+
}
|
|
112
|
+
})();
|
|
113
|
+
const existingPromise = inFlightRequests.get(key);
|
|
114
|
+
if (existingPromise) {
|
|
115
|
+
requestPromise = existingPromise;
|
|
116
|
+
} else {
|
|
117
|
+
inFlightRequests.set(key, requestPromise);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return requestPromise;
|
|
121
|
+
};
|
|
122
|
+
const clearCache = (key) => {
|
|
123
|
+
resultCache.delete(key);
|
|
124
|
+
cacheInvalidatedAt.set(key, Date.now());
|
|
125
|
+
inFlightRequests.delete(key);
|
|
126
|
+
};
|
|
127
|
+
const clearAllCache = () => {
|
|
128
|
+
resultCache.clear();
|
|
129
|
+
cacheInvalidatedAt.clear();
|
|
130
|
+
inFlightRequests.clear();
|
|
131
|
+
};
|
|
132
|
+
const getStats = () => ({
|
|
133
|
+
inFlight: inFlightRequests.size,
|
|
134
|
+
cached: resultCache.size
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
deduplicate,
|
|
138
|
+
clearCache,
|
|
139
|
+
clearAllCache,
|
|
140
|
+
getStats
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
var sharedSubscriptionDeduplicator = null;
|
|
144
|
+
function getSharedDeduplicator(options) {
|
|
145
|
+
if (!sharedSubscriptionDeduplicator) {
|
|
146
|
+
sharedSubscriptionDeduplicator = createRequestDeduplicator({
|
|
147
|
+
cacheTTL: 2e3,
|
|
148
|
+
// Cache results for 2 seconds
|
|
149
|
+
maxCacheSize: 1e3,
|
|
150
|
+
// Maximum cache entries
|
|
151
|
+
cacheErrors: true,
|
|
152
|
+
// Cache error results too
|
|
153
|
+
...options
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return sharedSubscriptionDeduplicator;
|
|
157
|
+
}
|
|
158
|
+
async function checkSubscription(request, options = {}) {
|
|
159
|
+
try {
|
|
160
|
+
const { requireUserId, getUserEmailFromRequest, getUserNameFromRequest } = await import("@solvapay/auth");
|
|
161
|
+
const userIdOrError = requireUserId(request);
|
|
162
|
+
if (userIdOrError instanceof Response) {
|
|
163
|
+
const clonedResponse = userIdOrError.clone();
|
|
164
|
+
const body = await clonedResponse.json().catch(() => ({ error: "Unauthorized" }));
|
|
165
|
+
return import_server.NextResponse.json(body, { status: userIdOrError.status });
|
|
166
|
+
}
|
|
167
|
+
const userId = userIdOrError;
|
|
168
|
+
const email = options.includeEmail !== false ? await getUserEmailFromRequest(request) : null;
|
|
169
|
+
const name = options.includeName !== false ? await getUserNameFromRequest(request) : null;
|
|
170
|
+
const deduplicator = getSharedDeduplicator(options.deduplication);
|
|
171
|
+
const response = await deduplicator.deduplicate(userId, async () => {
|
|
172
|
+
try {
|
|
173
|
+
const solvaPay = options.solvaPay || (0, import_server2.createSolvaPay)();
|
|
174
|
+
const ensuredCustomerRef = await solvaPay.ensureCustomer(userId, userId, {
|
|
175
|
+
email: email || void 0,
|
|
176
|
+
name: name || void 0
|
|
177
|
+
});
|
|
178
|
+
const customer = await solvaPay.getCustomer({ customerRef: ensuredCustomerRef });
|
|
179
|
+
const now = /* @__PURE__ */ new Date();
|
|
180
|
+
const filteredSubscriptions = (customer.subscriptions || []).filter((sub) => {
|
|
181
|
+
const subAny = sub;
|
|
182
|
+
const isCancelled = sub.status === "cancelled" || subAny.cancelledAt;
|
|
183
|
+
if (!isCancelled) {
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
if (isCancelled && subAny.endDate) {
|
|
187
|
+
const endDate = new Date(subAny.endDate);
|
|
188
|
+
const isFuture = endDate > now;
|
|
189
|
+
return isFuture;
|
|
190
|
+
}
|
|
191
|
+
return false;
|
|
192
|
+
});
|
|
193
|
+
const result = {
|
|
194
|
+
customerRef: customer.customerRef || userId,
|
|
195
|
+
email: customer.email,
|
|
196
|
+
name: customer.name,
|
|
197
|
+
subscriptions: filteredSubscriptions
|
|
198
|
+
};
|
|
199
|
+
return result;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
console.error("[checkSubscription] Error fetching customer:", error);
|
|
202
|
+
return {
|
|
203
|
+
customerRef: userId,
|
|
204
|
+
subscriptions: []
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
return response;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
console.error("Check subscription failed:", error);
|
|
211
|
+
if (error instanceof import_core.SolvaPayError) {
|
|
212
|
+
return import_server.NextResponse.json(
|
|
213
|
+
{ error: error.message },
|
|
214
|
+
{ status: 500 }
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
218
|
+
return import_server.NextResponse.json(
|
|
219
|
+
{ error: "Failed to check subscription", details: errorMessage },
|
|
220
|
+
{ status: 500 }
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function clearSubscriptionCache(userId) {
|
|
225
|
+
const deduplicator = getSharedDeduplicator();
|
|
226
|
+
deduplicator.clearCache(userId);
|
|
227
|
+
}
|
|
228
|
+
function clearAllSubscriptionCache() {
|
|
229
|
+
const deduplicator = getSharedDeduplicator();
|
|
230
|
+
deduplicator.clearAllCache();
|
|
231
|
+
}
|
|
232
|
+
function getSubscriptionCacheStats() {
|
|
233
|
+
const deduplicator = getSharedDeduplicator();
|
|
234
|
+
return deduplicator.getStats();
|
|
235
|
+
}
|
|
236
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
237
|
+
0 && (module.exports = {
|
|
238
|
+
checkSubscription,
|
|
239
|
+
clearAllSubscriptionCache,
|
|
240
|
+
clearSubscriptionCache,
|
|
241
|
+
getSubscriptionCacheStats
|
|
242
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { SolvaPay } from '@solvapay/server';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SolvaPay Next.js SDK
|
|
6
|
+
*
|
|
7
|
+
* Framework-specific helpers and utilities for Next.js API routes.
|
|
8
|
+
* These utilities provide common patterns with built-in optimizations
|
|
9
|
+
* like request deduplication and caching.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Request deduplication and caching options
|
|
14
|
+
*/
|
|
15
|
+
interface RequestDeduplicationOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Time-to-live for cached results in milliseconds (default: 2000)
|
|
18
|
+
* Set to 0 to disable caching (only deduplicate concurrent requests)
|
|
19
|
+
*/
|
|
20
|
+
cacheTTL?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Maximum cache size before cleanup (default: 1000)
|
|
23
|
+
* When exceeded, oldest entries are removed
|
|
24
|
+
*/
|
|
25
|
+
maxCacheSize?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Whether to cache error results (default: true)
|
|
28
|
+
* When false, only successful results are cached
|
|
29
|
+
*/
|
|
30
|
+
cacheErrors?: boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Subscription check result
|
|
34
|
+
*/
|
|
35
|
+
interface SubscriptionCheckResult {
|
|
36
|
+
customerRef: string;
|
|
37
|
+
email?: string;
|
|
38
|
+
name?: string;
|
|
39
|
+
subscriptions: Array<{
|
|
40
|
+
reference: string;
|
|
41
|
+
planName?: string;
|
|
42
|
+
agentName?: string;
|
|
43
|
+
status?: string;
|
|
44
|
+
startDate?: string;
|
|
45
|
+
[key: string]: unknown;
|
|
46
|
+
}>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Options for checking subscriptions
|
|
50
|
+
*/
|
|
51
|
+
interface CheckSubscriptionOptions {
|
|
52
|
+
/**
|
|
53
|
+
* Request deduplication options
|
|
54
|
+
* Default: { cacheTTL: 2000, maxCacheSize: 1000, cacheErrors: true }
|
|
55
|
+
*/
|
|
56
|
+
deduplication?: RequestDeduplicationOptions;
|
|
57
|
+
/**
|
|
58
|
+
* Custom SolvaPay instance (optional)
|
|
59
|
+
* If not provided, a new instance will be created
|
|
60
|
+
*/
|
|
61
|
+
solvaPay?: SolvaPay;
|
|
62
|
+
/**
|
|
63
|
+
* Whether to include user email in customer data
|
|
64
|
+
* Default: true
|
|
65
|
+
*/
|
|
66
|
+
includeEmail?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Whether to include user name in customer data
|
|
69
|
+
* Default: true
|
|
70
|
+
*/
|
|
71
|
+
includeName?: boolean;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Check user subscription status
|
|
75
|
+
*
|
|
76
|
+
* This helper function:
|
|
77
|
+
* 1. Extracts user ID from request (via requireUserId from @solvapay/auth)
|
|
78
|
+
* 2. Gets user email and name from Supabase JWT token
|
|
79
|
+
* 3. Ensures customer exists in SolvaPay
|
|
80
|
+
* 4. Returns customer subscription information
|
|
81
|
+
* 5. Handles deduplication automatically
|
|
82
|
+
*
|
|
83
|
+
* @param request - Next.js request object (NextRequest extends Request, so Request is accepted)
|
|
84
|
+
* @param options - Configuration options
|
|
85
|
+
* @returns Subscription check result or error response
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```typescript
|
|
89
|
+
* import { NextRequest } from 'next/server';
|
|
90
|
+
* import { checkSubscription } from '@solvapay/next';
|
|
91
|
+
*
|
|
92
|
+
* export async function GET(request: NextRequest) {
|
|
93
|
+
* const result = await checkSubscription(request);
|
|
94
|
+
* if (result instanceof NextResponse) {
|
|
95
|
+
* return result; // Error response
|
|
96
|
+
* }
|
|
97
|
+
* return NextResponse.json(result);
|
|
98
|
+
* }
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
declare function checkSubscription(request: Request, options?: CheckSubscriptionOptions): Promise<SubscriptionCheckResult | NextResponse>;
|
|
102
|
+
/**
|
|
103
|
+
* Clear subscription cache for a specific user
|
|
104
|
+
*
|
|
105
|
+
* Useful when you know subscription status has changed
|
|
106
|
+
* (e.g., after a successful checkout or subscription update)
|
|
107
|
+
*
|
|
108
|
+
* @param userId - User ID to clear cache for
|
|
109
|
+
*/
|
|
110
|
+
declare function clearSubscriptionCache(userId: string): void;
|
|
111
|
+
/**
|
|
112
|
+
* Clear all subscription cache entries
|
|
113
|
+
*
|
|
114
|
+
* Useful for testing or when you need to force fresh lookups
|
|
115
|
+
*/
|
|
116
|
+
declare function clearAllSubscriptionCache(): void;
|
|
117
|
+
/**
|
|
118
|
+
* Get subscription cache statistics
|
|
119
|
+
*
|
|
120
|
+
* Useful for monitoring and debugging
|
|
121
|
+
*
|
|
122
|
+
* @returns Cache statistics
|
|
123
|
+
*/
|
|
124
|
+
declare function getSubscriptionCacheStats(): {
|
|
125
|
+
inFlight: number;
|
|
126
|
+
cached: number;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export { type CheckSubscriptionOptions, type RequestDeduplicationOptions, type SubscriptionCheckResult, checkSubscription, clearAllSubscriptionCache, clearSubscriptionCache, getSubscriptionCacheStats };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { SolvaPay } from '@solvapay/server';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SolvaPay Next.js SDK
|
|
6
|
+
*
|
|
7
|
+
* Framework-specific helpers and utilities for Next.js API routes.
|
|
8
|
+
* These utilities provide common patterns with built-in optimizations
|
|
9
|
+
* like request deduplication and caching.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Request deduplication and caching options
|
|
14
|
+
*/
|
|
15
|
+
interface RequestDeduplicationOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Time-to-live for cached results in milliseconds (default: 2000)
|
|
18
|
+
* Set to 0 to disable caching (only deduplicate concurrent requests)
|
|
19
|
+
*/
|
|
20
|
+
cacheTTL?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Maximum cache size before cleanup (default: 1000)
|
|
23
|
+
* When exceeded, oldest entries are removed
|
|
24
|
+
*/
|
|
25
|
+
maxCacheSize?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Whether to cache error results (default: true)
|
|
28
|
+
* When false, only successful results are cached
|
|
29
|
+
*/
|
|
30
|
+
cacheErrors?: boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Subscription check result
|
|
34
|
+
*/
|
|
35
|
+
interface SubscriptionCheckResult {
|
|
36
|
+
customerRef: string;
|
|
37
|
+
email?: string;
|
|
38
|
+
name?: string;
|
|
39
|
+
subscriptions: Array<{
|
|
40
|
+
reference: string;
|
|
41
|
+
planName?: string;
|
|
42
|
+
agentName?: string;
|
|
43
|
+
status?: string;
|
|
44
|
+
startDate?: string;
|
|
45
|
+
[key: string]: unknown;
|
|
46
|
+
}>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Options for checking subscriptions
|
|
50
|
+
*/
|
|
51
|
+
interface CheckSubscriptionOptions {
|
|
52
|
+
/**
|
|
53
|
+
* Request deduplication options
|
|
54
|
+
* Default: { cacheTTL: 2000, maxCacheSize: 1000, cacheErrors: true }
|
|
55
|
+
*/
|
|
56
|
+
deduplication?: RequestDeduplicationOptions;
|
|
57
|
+
/**
|
|
58
|
+
* Custom SolvaPay instance (optional)
|
|
59
|
+
* If not provided, a new instance will be created
|
|
60
|
+
*/
|
|
61
|
+
solvaPay?: SolvaPay;
|
|
62
|
+
/**
|
|
63
|
+
* Whether to include user email in customer data
|
|
64
|
+
* Default: true
|
|
65
|
+
*/
|
|
66
|
+
includeEmail?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Whether to include user name in customer data
|
|
69
|
+
* Default: true
|
|
70
|
+
*/
|
|
71
|
+
includeName?: boolean;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Check user subscription status
|
|
75
|
+
*
|
|
76
|
+
* This helper function:
|
|
77
|
+
* 1. Extracts user ID from request (via requireUserId from @solvapay/auth)
|
|
78
|
+
* 2. Gets user email and name from Supabase JWT token
|
|
79
|
+
* 3. Ensures customer exists in SolvaPay
|
|
80
|
+
* 4. Returns customer subscription information
|
|
81
|
+
* 5. Handles deduplication automatically
|
|
82
|
+
*
|
|
83
|
+
* @param request - Next.js request object (NextRequest extends Request, so Request is accepted)
|
|
84
|
+
* @param options - Configuration options
|
|
85
|
+
* @returns Subscription check result or error response
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```typescript
|
|
89
|
+
* import { NextRequest } from 'next/server';
|
|
90
|
+
* import { checkSubscription } from '@solvapay/next';
|
|
91
|
+
*
|
|
92
|
+
* export async function GET(request: NextRequest) {
|
|
93
|
+
* const result = await checkSubscription(request);
|
|
94
|
+
* if (result instanceof NextResponse) {
|
|
95
|
+
* return result; // Error response
|
|
96
|
+
* }
|
|
97
|
+
* return NextResponse.json(result);
|
|
98
|
+
* }
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
declare function checkSubscription(request: Request, options?: CheckSubscriptionOptions): Promise<SubscriptionCheckResult | NextResponse>;
|
|
102
|
+
/**
|
|
103
|
+
* Clear subscription cache for a specific user
|
|
104
|
+
*
|
|
105
|
+
* Useful when you know subscription status has changed
|
|
106
|
+
* (e.g., after a successful checkout or subscription update)
|
|
107
|
+
*
|
|
108
|
+
* @param userId - User ID to clear cache for
|
|
109
|
+
*/
|
|
110
|
+
declare function clearSubscriptionCache(userId: string): void;
|
|
111
|
+
/**
|
|
112
|
+
* Clear all subscription cache entries
|
|
113
|
+
*
|
|
114
|
+
* Useful for testing or when you need to force fresh lookups
|
|
115
|
+
*/
|
|
116
|
+
declare function clearAllSubscriptionCache(): void;
|
|
117
|
+
/**
|
|
118
|
+
* Get subscription cache statistics
|
|
119
|
+
*
|
|
120
|
+
* Useful for monitoring and debugging
|
|
121
|
+
*
|
|
122
|
+
* @returns Cache statistics
|
|
123
|
+
*/
|
|
124
|
+
declare function getSubscriptionCacheStats(): {
|
|
125
|
+
inFlight: number;
|
|
126
|
+
cached: number;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export { type CheckSubscriptionOptions, type RequestDeduplicationOptions, type SubscriptionCheckResult, checkSubscription, clearAllSubscriptionCache, clearSubscriptionCache, getSubscriptionCacheStats };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { NextResponse } from "next/server";
|
|
3
|
+
import { createSolvaPay } from "@solvapay/server";
|
|
4
|
+
import { SolvaPayError } from "@solvapay/core";
|
|
5
|
+
function createRequestDeduplicator(options = {}) {
|
|
6
|
+
const {
|
|
7
|
+
cacheTTL = 2e3,
|
|
8
|
+
maxCacheSize = 1e3,
|
|
9
|
+
cacheErrors = true
|
|
10
|
+
} = options;
|
|
11
|
+
const inFlightRequests = /* @__PURE__ */ new Map();
|
|
12
|
+
const resultCache = /* @__PURE__ */ new Map();
|
|
13
|
+
const cacheInvalidatedAt = /* @__PURE__ */ new Map();
|
|
14
|
+
let cleanupInterval = null;
|
|
15
|
+
if (cacheTTL > 0) {
|
|
16
|
+
cleanupInterval = setInterval(() => {
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
const entriesToDelete = [];
|
|
19
|
+
for (const [key, cached] of resultCache.entries()) {
|
|
20
|
+
if (now - cached.timestamp >= cacheTTL) {
|
|
21
|
+
entriesToDelete.push(key);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
for (const key of entriesToDelete) {
|
|
25
|
+
resultCache.delete(key);
|
|
26
|
+
cacheInvalidatedAt.delete(key);
|
|
27
|
+
}
|
|
28
|
+
if (resultCache.size > maxCacheSize) {
|
|
29
|
+
const sortedEntries = Array.from(resultCache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp);
|
|
30
|
+
const toRemove = sortedEntries.slice(0, resultCache.size - maxCacheSize);
|
|
31
|
+
for (const [key] of toRemove) {
|
|
32
|
+
resultCache.delete(key);
|
|
33
|
+
cacheInvalidatedAt.delete(key);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}, Math.min(cacheTTL, 1e3));
|
|
37
|
+
}
|
|
38
|
+
const deduplicate = async (key, fn) => {
|
|
39
|
+
if (cacheTTL > 0) {
|
|
40
|
+
const cached = resultCache.get(key);
|
|
41
|
+
if (cached && Date.now() - cached.timestamp < cacheTTL) {
|
|
42
|
+
return cached.data;
|
|
43
|
+
} else if (cached) {
|
|
44
|
+
resultCache.delete(key);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
let requestPromise = inFlightRequests.get(key);
|
|
48
|
+
if (!requestPromise) {
|
|
49
|
+
const requestStartTime = Date.now();
|
|
50
|
+
requestPromise = (async () => {
|
|
51
|
+
try {
|
|
52
|
+
const result = await fn();
|
|
53
|
+
const invalidatedAt = cacheInvalidatedAt.get(key);
|
|
54
|
+
const shouldCache = !invalidatedAt || invalidatedAt < requestStartTime;
|
|
55
|
+
if (cacheTTL > 0 && shouldCache) {
|
|
56
|
+
resultCache.set(key, {
|
|
57
|
+
data: result,
|
|
58
|
+
timestamp: Date.now()
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
const invalidatedAt = cacheInvalidatedAt.get(key);
|
|
64
|
+
const shouldCache = !invalidatedAt || invalidatedAt < requestStartTime;
|
|
65
|
+
if (cacheTTL > 0 && cacheErrors && shouldCache) {
|
|
66
|
+
resultCache.set(key, {
|
|
67
|
+
data: error,
|
|
68
|
+
timestamp: Date.now()
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
throw error;
|
|
72
|
+
} finally {
|
|
73
|
+
inFlightRequests.delete(key);
|
|
74
|
+
}
|
|
75
|
+
})();
|
|
76
|
+
const existingPromise = inFlightRequests.get(key);
|
|
77
|
+
if (existingPromise) {
|
|
78
|
+
requestPromise = existingPromise;
|
|
79
|
+
} else {
|
|
80
|
+
inFlightRequests.set(key, requestPromise);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return requestPromise;
|
|
84
|
+
};
|
|
85
|
+
const clearCache = (key) => {
|
|
86
|
+
resultCache.delete(key);
|
|
87
|
+
cacheInvalidatedAt.set(key, Date.now());
|
|
88
|
+
inFlightRequests.delete(key);
|
|
89
|
+
};
|
|
90
|
+
const clearAllCache = () => {
|
|
91
|
+
resultCache.clear();
|
|
92
|
+
cacheInvalidatedAt.clear();
|
|
93
|
+
inFlightRequests.clear();
|
|
94
|
+
};
|
|
95
|
+
const getStats = () => ({
|
|
96
|
+
inFlight: inFlightRequests.size,
|
|
97
|
+
cached: resultCache.size
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
deduplicate,
|
|
101
|
+
clearCache,
|
|
102
|
+
clearAllCache,
|
|
103
|
+
getStats
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
var sharedSubscriptionDeduplicator = null;
|
|
107
|
+
function getSharedDeduplicator(options) {
|
|
108
|
+
if (!sharedSubscriptionDeduplicator) {
|
|
109
|
+
sharedSubscriptionDeduplicator = createRequestDeduplicator({
|
|
110
|
+
cacheTTL: 2e3,
|
|
111
|
+
// Cache results for 2 seconds
|
|
112
|
+
maxCacheSize: 1e3,
|
|
113
|
+
// Maximum cache entries
|
|
114
|
+
cacheErrors: true,
|
|
115
|
+
// Cache error results too
|
|
116
|
+
...options
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return sharedSubscriptionDeduplicator;
|
|
120
|
+
}
|
|
121
|
+
async function checkSubscription(request, options = {}) {
|
|
122
|
+
try {
|
|
123
|
+
const { requireUserId, getUserEmailFromRequest, getUserNameFromRequest } = await import("@solvapay/auth");
|
|
124
|
+
const userIdOrError = requireUserId(request);
|
|
125
|
+
if (userIdOrError instanceof Response) {
|
|
126
|
+
const clonedResponse = userIdOrError.clone();
|
|
127
|
+
const body = await clonedResponse.json().catch(() => ({ error: "Unauthorized" }));
|
|
128
|
+
return NextResponse.json(body, { status: userIdOrError.status });
|
|
129
|
+
}
|
|
130
|
+
const userId = userIdOrError;
|
|
131
|
+
const email = options.includeEmail !== false ? await getUserEmailFromRequest(request) : null;
|
|
132
|
+
const name = options.includeName !== false ? await getUserNameFromRequest(request) : null;
|
|
133
|
+
const deduplicator = getSharedDeduplicator(options.deduplication);
|
|
134
|
+
const response = await deduplicator.deduplicate(userId, async () => {
|
|
135
|
+
try {
|
|
136
|
+
const solvaPay = options.solvaPay || createSolvaPay();
|
|
137
|
+
const ensuredCustomerRef = await solvaPay.ensureCustomer(userId, userId, {
|
|
138
|
+
email: email || void 0,
|
|
139
|
+
name: name || void 0
|
|
140
|
+
});
|
|
141
|
+
const customer = await solvaPay.getCustomer({ customerRef: ensuredCustomerRef });
|
|
142
|
+
const now = /* @__PURE__ */ new Date();
|
|
143
|
+
const filteredSubscriptions = (customer.subscriptions || []).filter((sub) => {
|
|
144
|
+
const subAny = sub;
|
|
145
|
+
const isCancelled = sub.status === "cancelled" || subAny.cancelledAt;
|
|
146
|
+
if (!isCancelled) {
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
if (isCancelled && subAny.endDate) {
|
|
150
|
+
const endDate = new Date(subAny.endDate);
|
|
151
|
+
const isFuture = endDate > now;
|
|
152
|
+
return isFuture;
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
});
|
|
156
|
+
const result = {
|
|
157
|
+
customerRef: customer.customerRef || userId,
|
|
158
|
+
email: customer.email,
|
|
159
|
+
name: customer.name,
|
|
160
|
+
subscriptions: filteredSubscriptions
|
|
161
|
+
};
|
|
162
|
+
return result;
|
|
163
|
+
} catch (error) {
|
|
164
|
+
console.error("[checkSubscription] Error fetching customer:", error);
|
|
165
|
+
return {
|
|
166
|
+
customerRef: userId,
|
|
167
|
+
subscriptions: []
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
return response;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
console.error("Check subscription failed:", error);
|
|
174
|
+
if (error instanceof SolvaPayError) {
|
|
175
|
+
return NextResponse.json(
|
|
176
|
+
{ error: error.message },
|
|
177
|
+
{ status: 500 }
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
181
|
+
return NextResponse.json(
|
|
182
|
+
{ error: "Failed to check subscription", details: errorMessage },
|
|
183
|
+
{ status: 500 }
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function clearSubscriptionCache(userId) {
|
|
188
|
+
const deduplicator = getSharedDeduplicator();
|
|
189
|
+
deduplicator.clearCache(userId);
|
|
190
|
+
}
|
|
191
|
+
function clearAllSubscriptionCache() {
|
|
192
|
+
const deduplicator = getSharedDeduplicator();
|
|
193
|
+
deduplicator.clearAllCache();
|
|
194
|
+
}
|
|
195
|
+
function getSubscriptionCacheStats() {
|
|
196
|
+
const deduplicator = getSharedDeduplicator();
|
|
197
|
+
return deduplicator.getStats();
|
|
198
|
+
}
|
|
199
|
+
export {
|
|
200
|
+
checkSubscription,
|
|
201
|
+
clearAllSubscriptionCache,
|
|
202
|
+
clearSubscriptionCache,
|
|
203
|
+
getSubscriptionCacheStats
|
|
204
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@solvapay/next",
|
|
3
|
+
"version": "1.0.0-preview.7",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.cjs",
|
|
6
|
+
"module": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"require": "./dist/index.cjs"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/solvapay/solvapay-sdk.git",
|
|
25
|
+
"directory": "packages/next"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18.17"
|
|
29
|
+
},
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@solvapay/auth": "1.0.0-preview.7",
|
|
33
|
+
"@solvapay/core": "1.0.0-preview.7",
|
|
34
|
+
"@solvapay/server": "1.0.0-preview.7"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"next": ">=13.0.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"next": "^14.0.0",
|
|
41
|
+
"tsup": "^8.0.1",
|
|
42
|
+
"typescript": "^5.5.4",
|
|
43
|
+
"vitest": "^2.0.5",
|
|
44
|
+
"@solvapay/test-utils": "0.0.0"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --tsconfig tsconfig.build.json --external next",
|
|
48
|
+
"test": "vitest run || exit 0",
|
|
49
|
+
"test:watch": "vitest",
|
|
50
|
+
"lint": "echo 'no lint yet'"
|
|
51
|
+
}
|
|
52
|
+
}
|