diff --git a/hooks/useSdkCachedBalance.ts b/hooks/useSdkCachedBalance.ts index 6e421746..b604535c 100644 --- a/hooks/useSdkCachedBalance.ts +++ b/hooks/useSdkCachedBalance.ts @@ -19,7 +19,8 @@ export function useSdkCachedBalance(): number { let unsubscribe: (() => void) | null = null; const computeBalance = () => { - const tokens = storeRef.current.store.getState().cachedTokens; + // Use apiKeys for cached balance calculation (renamed from cachedTokens) + const tokens = storeRef.current.store.getState().apiKeys; const total = tokens.reduce((sum, t) => sum + (t.balance || 0), 0); setCachedBalance(total); }; diff --git a/hooks/useSdkClient.ts b/hooks/useSdkClient.ts index 9c826b8c..b2d9ffdd 100644 --- a/hooks/useSdkClient.ts +++ b/hooks/useSdkClient.ts @@ -36,11 +36,6 @@ const createPendingDeps = (): { getAllProvidersModels: () => ({}), }; const pendingStorage: StorageAdapter = { - getToken: pendingHandler, - setToken: pendingHandler, - removeToken: pendingHandler, - updateTokenBalance: pendingHandler, - getCachedTokenDistribution: () => [], saveProviderInfo: pendingHandler, getProviderInfo: () => null, getApiKey: () => null, diff --git a/next.config.ts b/next.config.ts index df646278..ab1df273 100644 --- a/next.config.ts +++ b/next.config.ts @@ -56,6 +56,28 @@ const nextConfig: NextConfig = { }, // Add HMR configuration to prevent ping errors webpack: (config, { dev, isServer }) => { + // Prevent native Node.js modules from being bundled for the client + if (!isServer) { + config.resolve.fallback = { + ...config.resolve.fallback, + fs: false, + path: false, + crypto: false, + }; + // Ignore native modules that shouldn't be bundled for client + config.module.rules.push({ + test: /node_modules\/better-sqlite3\/.*/, + use: { loader: "null-loader" }, + }); + config.module.rules.push({ + test: /node_modules\/bindings\/.*/, + use: { loader: "null-loader" }, + }); + config.module.rules.push({ + test: /node_modules\/file-uri-to-path\/.*/, + use: { loader: "null-loader" }, + }); + } if (dev && !isServer) { config.watchOptions = { poll: 1000, @@ -64,7 +86,7 @@ const nextConfig: NextConfig = { } return config; }, - serverExternalPackages: [], + serverExternalPackages: ["better-sqlite3"], // Silence Next 16 Turbopack + webpack plugin warning (next-pwa injects webpack config) // See: https://nextjs.org/docs/app/api-reference/next-config-js/turbopack turbopack: {}, diff --git a/package-lock.json b/package-lock.json index d996575b..a45cd972 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "rxjs": "^7.8.1", + "server-only": "^0.0.1", "sonner": "^2.0.3", "tailwind-merge": "^3.2.0", "tailwindcss-animate": "^1.0.7", @@ -81,6 +82,7 @@ "eslint-config-next": "16.0.1", "eslint-config-prettier": "^10.1.8", "msw": "^2.11.3", + "null-loader": "^4.0.1", "playwright": "^1.56.1", "prettier": "^3.7.4", "tailwindcss": "^4", @@ -13234,6 +13236,46 @@ "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", "license": "MIT" }, + "node_modules/null-loader": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/null-loader/-/null-loader-4.0.1.tgz", + "integrity": "sha512-pxqVbi4U6N26lq+LmgIbB5XATP0VdZKOG25DhHi8btMmJJefGArFyDg1yc4U3hWCJbMqSrw0qyrz1UQX+qYXqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/null-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -15106,6 +15148,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -18324,7 +18372,7 @@ }, "sdk": { "name": "@routstr/sdk", - "version": "0.1.7", + "version": "0.2.5", "license": "MIT", "dependencies": { "@cashu/cashu-ts": "^3.1.1", diff --git a/package.json b/package.json index 3067c371..b7d267d5 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "rxjs": "^7.8.1", + "server-only": "^0.0.1", "sonner": "^2.0.3", "tailwind-merge": "^3.2.0", "tailwindcss-animate": "^1.0.7", @@ -105,6 +106,7 @@ "eslint-config-next": "16.0.1", "eslint-config-prettier": "^10.1.8", "msw": "^2.11.3", + "null-loader": "^4.0.1", "playwright": "^1.56.1", "prettier": "^3.7.4", "tailwindcss": "^4", diff --git a/scripts/check-balance.ts b/scripts/check-balance.ts index 2ec5234b..2c7a81a2 100644 --- a/scripts/check-balance.ts +++ b/scripts/check-balance.ts @@ -9,9 +9,9 @@ import { getDecodedToken } from "@cashu/cashu-ts"; interface BalanceSummary { walletBalance: Record; - cachedTokens: Array<{ baseUrl: string; balance: number }>; apiKeys: Array<{ baseUrl: string; balance: number }>; childKeys: Array<{ parentBaseUrl: string; balance: number }>; + cachedReceiveTokens: Array<{ baseUrl: string; amount: number }>; } async function runWalletCommand(args: string[]): Promise { @@ -153,14 +153,16 @@ async function main(): Promise { } console.log(` Total: ${totalWallet} sats\n`); - console.log("=== Cached Tokens ==="); - const cachedTokens = store.getState().cachedTokens; - const totalCached = cachedTokens.reduce( - (sum, t) => sum + (t.balance || 0), + console.log("=== Cached Receive Tokens ==="); + const cachedReceiveTokens = store.getState().cachedReceiveTokens; + const totalCached = cachedReceiveTokens.reduce( + (sum, t) => sum + (t.amount || 0), 0 ); - for (const token of cachedTokens) { - console.log(` ${token.baseUrl}: ${token.balance || 0} sats`); + for (const token of cachedReceiveTokens) { + // cachedReceiveTokens don't have baseUrl, just show token preview + const tokenPreview = token.token.substring(0, 20) + "..."; + console.log(` ${tokenPreview}: ${token.amount || 0} sats (${token.unit})`); } console.log(` Total: ${totalCached} sats\n`); diff --git a/scripts/refund-all.ts b/scripts/refund-all.ts index da790578..a97fdb1e 100644 --- a/scripts/refund-all.ts +++ b/scripts/refund-all.ts @@ -81,17 +81,18 @@ async function main(): Promise { const storageAdapter = createStorageAdapterFromStore(store); const providerRegistry = createProviderRegistryFromStore(store); - const pendingDistribution = storageAdapter.getCachedTokenDistribution(); + // Get cached receive tokens (tokens that failed to receive) + const cachedReceiveTokens = storageAdapter.getCachedReceiveTokens(); const apiKeysStored = storageAdapter.getApiKeyDistribution(); - if (pendingDistribution.length === 0 && apiKeysStored.length === 0) { + if (cachedReceiveTokens.length === 0 && apiKeysStored.length === 0) { console.log("No pending tokens to refund"); return; } - console.log(`Found ${pendingDistribution.length} pending tokens:`); - for (const pending of pendingDistribution) { - console.log(` - ${pending.baseUrl}: ${pending.amount} sats`); + console.log(`Found ${cachedReceiveTokens.length} cached receive tokens:`); + for (const pending of cachedReceiveTokens) { + console.log(` - ${pending.token.substring(0, 20)}...: ${pending.amount} sats (${pending.unit})`); } console.log(`Found ${apiKeysStored.length} apikeys:`); @@ -99,9 +100,8 @@ async function main(): Promise { console.log(` - ${apikey.baseUrl}: ${apikey.amount} sats`); } - const refundBaseUrls = pendingDistribution - .map((p) => p.baseUrl) - .concat(apiKeysStored.map((p) => p.baseUrl)); + // Use API keys for refund base URLs + const refundBaseUrls = apiKeysStored.map((p) => p.baseUrl); let mintUnits: Record = {}; diff --git a/scripts/set-client-ids.ts b/scripts/set-client-ids.ts index cccee3e8..a2db8ae3 100644 --- a/scripts/set-client-ids.ts +++ b/scripts/set-client-ids.ts @@ -113,10 +113,11 @@ Examples: const currentClientIds = store.getState().clientIds; if (mode === "set") { + // TypeScript narrows the type after the early exit check above const newEntry: ClientIdEntry = { - clientId, - name, - apiKey, + clientId: clientId as string, + name: name as string, + apiKey: apiKey as string, createdAt: Date.now(), lastUsed: null, }; @@ -129,19 +130,19 @@ Examples: let updated: ClientIdEntry[]; if (existingIndex !== -1) { - // Update existing + // Update existing - TypeScript knows name and apiKey are not null here due to earlier check updated = currentClientIds.map((e, i) => i === existingIndex - ? { ...e, name, apiKey, lastUsed: Date.now() } + ? { ...e, name: name as string, apiKey: apiKey as string, lastUsed: Date.now() } : e ); console.log(`ClientId "${clientId}" has been updated.\n`); } else { - // Add new + // Add new - TypeScript knows name and apiKey are not null here due to earlier check const newEntry: ClientIdEntry = { - clientId: clientId!, - name: name!, - apiKey: apiKey!, + clientId: clientId as string, + name: name as string, + apiKey: apiKey as string, createdAt: Date.now(), lastUsed: null, }; diff --git a/sdk/client/RoutstrClient.ts b/sdk/client/RoutstrClient.ts index f66810d6..a554775f 100644 --- a/sdk/client/RoutstrClient.ts +++ b/sdk/client/RoutstrClient.ts @@ -58,7 +58,7 @@ export interface FetchOptions { * RoutstrClient is the main SDK entry point */ export type AlertLevel = "max" | "min"; -export type RoutstrClientMode = "xcashu" | "apikeys"; +export type RoutstrClientMode = "xcashu" | "apikeys" | "lazyrefund"; export type DebugLevel = "DEBUG" | "WARN" | "ERROR"; const TOPUP_MARGIN = 1.2; diff --git a/sdk/routeRequests.ts b/sdk/routeRequests.ts index 37543560..49b1a5c0 100644 --- a/sdk/routeRequests.ts +++ b/sdk/routeRequests.ts @@ -50,8 +50,8 @@ export interface RouteRequestOptions { modelManager?: ModelManager; /** Optional: set RoutstrClient debug level */ debugLevel?: DebugLevel; - /** Optional: client mode (xcashu or apikeys) */ - mode?: "xcashu" | "apikeys"; + /** Optional: client mode (xcashu, apikeys, or lazyrefund) */ + mode?: "xcashu" | "apikeys" | "lazyrefund"; } export interface RouteRequestToNodeResponseOptions extends RouteRequestOptions { diff --git a/sdk/storage/drivers/sqlite.ts b/sdk/storage/drivers/sqlite.ts index 64eeb25a..4e810761 100644 --- a/sdk/storage/drivers/sqlite.ts +++ b/sdk/storage/drivers/sqlite.ts @@ -18,23 +18,26 @@ const isBun = (): boolean => { return typeof process.versions.bun !== "undefined"; }; -const createDatabase = (dbPath: string): BetterSqlite3Database => { +// Lazy-load better-sqlite3 to avoid bundling it for client-side code +let cachedDbModule: any = null; + +const loadDatabase = async (dbPath: string): Promise => { if (isBun()) { throw new Error( "SQLite driver not supported in Bun. Use createBunSqliteDriver() instead." ); } - let Database: any = null; try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - Database = require("better-sqlite3"); + if (!cachedDbModule) { + cachedDbModule = (await import("better-sqlite3")).default; + } + return new cachedDbModule(dbPath); } catch (error) { throw new Error( `better-sqlite3 is required for sqlite storage. Install it to use sqlite storage. (${error})` ); } - return new Database(dbPath); }; export const createSqliteDriver = ( @@ -43,21 +46,38 @@ export const createSqliteDriver = ( const dbPath = options.dbPath || "routstr.sqlite"; const tableName = options.tableName || "sdk_storage"; - const db = createDatabase(dbPath); - db.exec( - `CREATE TABLE IF NOT EXISTS ${tableName} (key TEXT PRIMARY KEY, value TEXT NOT NULL)` - ); + let db: BetterSqlite3Database; + let selectStmt: any; + let upsertStmt: any; + let deleteStmt: any; + + const initDb = async () => { + if (!db) { + db = await loadDatabase(dbPath); + db.exec( + `CREATE TABLE IF NOT EXISTS ${tableName} (key TEXT PRIMARY KEY, value TEXT NOT NULL)` + ); - const selectStmt = db.prepare(`SELECT value FROM ${tableName} WHERE key = ?`); - const upsertStmt = db.prepare( - `INSERT INTO ${tableName} (key, value) VALUES (?, ?) + selectStmt = db.prepare(`SELECT value FROM ${tableName} WHERE key = ?`); + upsertStmt = db.prepare( + `INSERT INTO ${tableName} (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value` - ); - const deleteStmt = db.prepare(`DELETE FROM ${tableName} WHERE key = ?`); + ); + deleteStmt = db.prepare(`DELETE FROM ${tableName} WHERE key = ?`); + } + }; + + // Initialize asynchronously on first use + const ensureInit = async () => { + if (!db) { + await initDb(); + } + }; return { async getItem(key: string, defaultValue: T): Promise { try { + await ensureInit(); const row = selectStmt.get(key); if (!row || typeof row.value !== "string") return defaultValue; try { @@ -75,6 +95,7 @@ export const createSqliteDriver = ( }, async setItem(key: string, value: T): Promise { try { + await ensureInit(); upsertStmt.run(key, JSON.stringify(value)); } catch (error) { console.error(`SQLite setItem failed for key "${key}":`, error); @@ -82,6 +103,7 @@ export const createSqliteDriver = ( }, async removeItem(key: string): Promise { try { + await ensureInit(); deleteStmt.run(key); } catch (error) { console.error(`SQLite removeItem failed for key "${key}":`, error); @@ -96,7 +118,9 @@ export async function createBunSqliteDriver( dbPath: string ): Promise { // @ts-ignore - bun:sqlite is only available at runtime in Bun environments - const SQLite = (await import("bun:sqlite")).default; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const SQLite = (await import(/* webpackIgnore: true */ "bun:sqlite")).default; const db = new SQLite(dbPath); db.run(` diff --git a/sdk/storage/usageTracking/sqlite.ts b/sdk/storage/usageTracking/sqlite.ts index f9ce8ea1..cc8c8eb7 100644 --- a/sdk/storage/usageTracking/sqlite.ts +++ b/sdk/storage/usageTracking/sqlite.ts @@ -27,23 +27,26 @@ const isBun = (): boolean => { return typeof process.versions.bun !== "undefined"; }; -const createDatabase = (dbPath: string): BetterSqlite3Database => { +// Lazy-load better-sqlite3 to avoid bundling it for client-side code +let cachedDbModule: any = null; + +const loadDatabase = async (dbPath: string): Promise => { if (isBun()) { throw new Error( "SQLite driver not supported in Bun. Use createMemoryDriver() instead." ); } - let Database: any = null; try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - Database = require("better-sqlite3"); + if (!cachedDbModule) { + cachedDbModule = (await import("better-sqlite3")).default; + } + return new cachedDbModule(dbPath); } catch (error) { throw new Error( `better-sqlite3 is required for sqlite usage tracking. Install it to use sqlite storage. (${error})` ); } - return new Database(dbPath); }; const buildWhereClause = ( @@ -88,39 +91,56 @@ export const createSqliteUsageTrackingDriver = ( ): UsageTrackingDriver => { const dbPath = options.dbPath || "routstr.sqlite"; const tableName = options.tableName || "usage_tracking"; - const db = createDatabase(dbPath); const legacyStorageDriver = options.legacyStorageDriver; - db.exec(` - CREATE TABLE IF NOT EXISTS ${tableName} ( - id TEXT PRIMARY KEY, - timestamp INTEGER NOT NULL, - model_id TEXT NOT NULL, - base_url TEXT NOT NULL, - request_id TEXT NOT NULL, - cost REAL NOT NULL, - sats_cost REAL NOT NULL, - prompt_tokens INTEGER NOT NULL, - completion_tokens INTEGER NOT NULL, - total_tokens INTEGER NOT NULL, - client TEXT, - session_id TEXT, - tags TEXT - ); - CREATE INDEX IF NOT EXISTS idx_${tableName}_timestamp ON ${tableName}(timestamp); - CREATE INDEX IF NOT EXISTS idx_${tableName}_model_id ON ${tableName}(model_id); - CREATE INDEX IF NOT EXISTS idx_${tableName}_base_url ON ${tableName}(base_url); - CREATE INDEX IF NOT EXISTS idx_${tableName}_session_id ON ${tableName}(session_id); - CREATE INDEX IF NOT EXISTS idx_${tableName}_client ON ${tableName}(client); - `); - - const insertStmt = db.prepare(` - INSERT OR REPLACE INTO ${tableName} ( - id, timestamp, model_id, base_url, request_id, - cost, sats_cost, prompt_tokens, completion_tokens, total_tokens, - client, session_id, tags - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); + let db: BetterSqlite3Database; + let insertStmt: any; + let preparedStmts: { [key: string]: any } = {}; + + const initDb = async () => { + if (!db) { + db = await loadDatabase(dbPath); + db.exec(` + CREATE TABLE IF NOT EXISTS ${tableName} ( + id TEXT PRIMARY KEY, + timestamp INTEGER NOT NULL, + model_id TEXT NOT NULL, + base_url TEXT NOT NULL, + request_id TEXT NOT NULL, + cost REAL NOT NULL, + sats_cost REAL NOT NULL, + prompt_tokens INTEGER NOT NULL, + completion_tokens INTEGER NOT NULL, + total_tokens INTEGER NOT NULL, + client TEXT, + session_id TEXT, + tags TEXT + ); + CREATE INDEX IF NOT EXISTS idx_${tableName}_timestamp ON ${tableName}(timestamp); + CREATE INDEX IF NOT EXISTS idx_${tableName}_model_id ON ${tableName}(model_id); + CREATE INDEX IF NOT EXISTS idx_${tableName}_base_url ON ${tableName}(base_url); + CREATE INDEX IF NOT EXISTS idx_${tableName}_session_id ON ${tableName}(session_id); + CREATE INDEX IF NOT EXISTS idx_${tableName}_client ON ${tableName}(client); + `); + + insertStmt = db.prepare(` + INSERT OR REPLACE INTO ${tableName} ( + id, timestamp, model_id, base_url, request_id, + cost, sats_cost, prompt_tokens, completion_tokens, total_tokens, + client, session_id, tags + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + } + }; + + // Lazy async initialization + let initPromise: Promise | null = null; + const ensureInit = async () => { + if (!initPromise) { + initPromise = initDb(); + } + await initPromise; + }; let migrationComplete = false; @@ -186,17 +206,27 @@ export const createSqliteUsageTrackingDriver = ( tags: typeof row.tags === "string" ? JSON.parse(row.tags) : undefined, }); + const getStatement = (sql: string): any => { + if (!preparedStmts[sql]) { + preparedStmts[sql] = db.prepare(sql); + } + return preparedStmts[sql]; + }; + return { async migrate(): Promise { + await ensureInit(); await ensureMigrated(); }, async append(entry: UsageTrackingEntry): Promise { + await ensureInit(); await ensureMigrated(); appendOne(entry); }, async appendMany(entries: UsageTrackingEntry[]): Promise { + await ensureInit(); await ensureMigrated(); for (const entry of entries) { appendOne(entry); @@ -204,10 +234,11 @@ export const createSqliteUsageTrackingDriver = ( }, async list(options: ListUsageTrackingOptions = {}): Promise { + await ensureInit(); await ensureMigrated(); const { sql, params } = buildWhereClause(options); const limitSql = typeof options.limit === "number" ? " LIMIT ?" : ""; - const stmt = db.prepare( + const stmt = getStatement( `SELECT * FROM ${tableName} ${sql} ORDER BY timestamp DESC${limitSql}` ); const rows = stmt.all( @@ -217,23 +248,26 @@ export const createSqliteUsageTrackingDriver = ( }, async count(options: Omit = {}): Promise { + await ensureInit(); await ensureMigrated(); const { sql, params } = buildWhereClause(options); - const stmt = db.prepare(`SELECT COUNT(*) as count FROM ${tableName} ${sql}`); + const stmt = getStatement(`SELECT COUNT(*) as count FROM ${tableName} ${sql}`); const row = stmt.get(...params); return Number(row?.count ?? 0); }, async deleteOlderThan(timestamp: number): Promise { + await ensureInit(); await ensureMigrated(); - const stmt = db.prepare(`DELETE FROM ${tableName} WHERE timestamp < ?`); + const stmt = getStatement(`DELETE FROM ${tableName} WHERE timestamp < ?`); const result = stmt.run(timestamp); return result.changes; }, async clear(): Promise { + await ensureInit(); await ensureMigrated(); - db.prepare(`DELETE FROM ${tableName}`).run(); + getStatement(`DELETE FROM ${tableName}`).run(); }, }; };