From e4e837e3393d6fb5db1eb20e699bde80efee57c4 Mon Sep 17 00:00:00 2001 From: Toni Tabak Date: Wed, 17 Dec 2025 21:28:38 +0100 Subject: [PATCH 1/3] feat: contractLoader --- __tests__/contract.test.ts | 13 ++- __tests__/utils/contractLoader.test.ts | 106 ++++++++++++++++++++ src/utils/contract.ts | 5 + src/utils/contractLoaderBrowser.ts | 20 ++++ src/utils/contractLoaderNode.ts | 132 +++++++++++++++++++++++++ tsup.config.ts | 33 +++++-- 6 files changed, 300 insertions(+), 9 deletions(-) create mode 100644 __tests__/utils/contractLoader.test.ts create mode 100644 src/utils/contractLoaderBrowser.ts create mode 100644 src/utils/contractLoaderNode.ts diff --git a/__tests__/contract.test.ts b/__tests__/contract.test.ts index d2c81e490..9e3ec2897 100644 --- a/__tests__/contract.test.ts +++ b/__tests__/contract.test.ts @@ -16,6 +16,7 @@ import { RpcError, ReceiptTx, RpcProvider, + contractLoader, } from '../src'; import { CONTRACTS, createTestProvider, getTestAccount, initializeMatcher } from './config'; @@ -244,10 +245,16 @@ describe('contract module', () => { }); test('factory deployment of new contract with constructor arguments as already compiled calldata', async () => { + const loadedContract = contractLoader( + '/Users/tabaktoni/Work/starknet.js/__mocks__/cairo/cairo294' + ); + + // Ensure casm is loaded + expect(loadedContract.casm).toBeDefined(); + const erc20 = await Contract.factory({ - contract: CONTRACTS.Erc20Oz100.sierra, - casm: CONTRACTS.Erc20Oz100.casm, - classHash: erc20ClassHash, + contract: loadedContract.sierra, + casm: loadedContract.casm!, account, constructorCalldata: erc20Constructor, }); diff --git a/__tests__/utils/contractLoader.test.ts b/__tests__/utils/contractLoader.test.ts new file mode 100644 index 000000000..8dbcd7398 --- /dev/null +++ b/__tests__/utils/contractLoader.test.ts @@ -0,0 +1,106 @@ +import path from 'path'; +import { contractLoader } from '../../src/utils/contract'; + +describe('contractLoader', () => { + const mocksBase = path.resolve(__dirname, '../../__mocks__'); + const mockDir = path.join(mocksBase, 'cairo/cairo210'); + const sierraFile = path.join(mockDir, 'cairo210.sierra.json'); + const casmFile = path.join(mockDir, 'cairo210.casm'); + + describe('Loading from directory', () => { + test('should load both sierra and casm from directory', () => { + const contract = contractLoader(mockDir); + + expect(contract).toBeDefined(); + expect(contract.casm).toBeDefined(); + expect(contract.sierra).toBeDefined(); + expect(contract.compiler).toBe('2.1.0'); + }); + }); + + describe('Loading from sierra file', () => { + test('should load sierra and find casm in same directory', () => { + const contract = contractLoader(sierraFile); + + expect(contract).toBeDefined(); + expect(contract.casm).toBeDefined(); + expect(contract.sierra).toBeDefined(); + expect(contract.compiler).toBe('2.1.0'); + }); + }); + + describe('Loading from casm file', () => { + test('should load casm and find sierra in same directory', () => { + const contract = contractLoader(casmFile); + + expect(contract).toBeDefined(); + expect(contract.casm).toBeDefined(); + expect(contract.sierra).toBeDefined(); + expect(contract.compiler).toBe('2.1.0'); + }); + }); + + describe('Error handling', () => { + test('should throw error if no sierra file found', () => { + // Try to load a casm-only file (blake2s) which should fail + const casmOnlyFile = path.join(mocksBase, 'cairo/blake2s_verification_contract.casm'); + + expect(() => contractLoader(casmOnlyFile)).toThrow('No .sierra.json file found'); + }); + }); + + describe('Sierra-only contracts', () => { + test('should return sierra without casm if no casm file exists', () => { + // Use complexInput which has only sierra file + const sierraOnlyDir = path.join(mocksBase, 'cairo/complexInput'); + const contract = contractLoader(sierraOnlyDir); + + expect(contract).toBeDefined(); + expect(contract.sierra).toBeDefined(); + expect(contract.casm).toBeUndefined(); + expect(contract.compiler).toBeUndefined(); + }); + }); + + describe('Integration with Contract class and declare', () => { + test('loaded contract should have correct structure for Contract and declare', () => { + // Load contract using contractLoader + const loadedContract = contractLoader(mockDir); + + // Verify the contract has correct structure for declare/deploy + expect(loadedContract.sierra).toBeDefined(); + expect(loadedContract.casm).toBeDefined(); + + // Verify sierra has required properties for Contract class + expect(loadedContract.sierra.abi).toBeDefined(); + expect(Array.isArray(loadedContract.sierra.abi)).toBe(true); + expect('sierra_program' in loadedContract.sierra).toBe(true); + + // Verify casm has required properties for declare + expect('bytecode' in loadedContract.casm!).toBe(true); + expect('compiler_version' in loadedContract.casm!).toBe(true); + + // Verify compiler version is extracted + expect(loadedContract.compiler).toBe('2.1.0'); + // Note: compiler_version exists on CompiledSierraCasm but not on LegacyContractClass + if (loadedContract.casm && 'compiler_version' in loadedContract.casm) { + expect(loadedContract.compiler).toBe(loadedContract.casm.compiler_version); + } + + // Verify the loaded contract structure matches what declareAndDeploy expects + // This validates that contractLoader returns properly parsed contracts with correct types + const declarePayload = { + contract: loadedContract.sierra, + casm: loadedContract.casm, + }; + + expect(declarePayload.contract).toBeDefined(); + expect(declarePayload.contract.abi).toBeDefined(); + expect(declarePayload.casm).toBeDefined(); + + // Verify ABI is properly parsed (not just a string) + expect(typeof declarePayload.contract.abi).toBe('object'); + expect(Array.isArray(declarePayload.contract.abi)).toBe(true); + }); + }); +}); diff --git a/src/utils/contract.ts b/src/utils/contract.ts index 3d5e0a4c4..1af445d4c 100644 --- a/src/utils/contract.ts +++ b/src/utils/contract.ts @@ -80,3 +80,8 @@ export function contractClassResponseToLegacyCompiledContract(ccr: ContractClass const contract = ccr as LegacyContractClass; return { ...contract, program: decompressProgram(contract.program) } as LegacyCompiledContract; } + +// Re-export LoadedContract type and contractLoader function from the Node.js-specific module +// Note: contractLoader uses Node.js fs/path APIs and is only available in Node.js environments +export type { LoadedContract } from './contractLoaderNode'; +export { contractLoader } from './contractLoaderNode'; diff --git a/src/utils/contractLoaderBrowser.ts b/src/utils/contractLoaderBrowser.ts new file mode 100644 index 000000000..64c99abda --- /dev/null +++ b/src/utils/contractLoaderBrowser.ts @@ -0,0 +1,20 @@ +import { CairoAssembly, CompiledSierra } from '../types/lib/contract/index'; + +export interface LoadedContract { + sierra: CompiledSierra; + casm?: CairoAssembly; + compiler?: string; +} + +/** + * Browser stub for contractLoader. + * This function is not available in browser environments. + * Use it only in Node.js environments (server-side or build scripts). + */ +export function contractLoader(_contractPath: string): LoadedContract { + throw new Error( + 'contractLoader is only available in Node.js environment. ' + + 'This function requires filesystem access and cannot be used in browsers. ' + + 'Load your contracts using other methods (e.g., import, fetch) in browser environments.' + ); +} diff --git a/src/utils/contractLoaderNode.ts b/src/utils/contractLoaderNode.ts new file mode 100644 index 000000000..92516f436 --- /dev/null +++ b/src/utils/contractLoaderNode.ts @@ -0,0 +1,132 @@ +import { CompiledSierra, CairoAssembly } from '../types/lib/contract/index'; +import { parse } from './json'; + +export interface LoadedContract { + sierra: CompiledSierra; + casm?: CairoAssembly; + compiler?: string; +} + +/** + * Loads a Cairo contract from the filesystem. + * + * Accepts either a directory path or a direct path to a .sierra.json or .casm file. + * - If a directory is provided: searches for .sierra.json and .casm files + * - If a file path is provided: loads that file and attempts to find the complementary file + * + * @param {string} contractPath - Path to a directory or a .sierra.json/.casm file + * @return {LoadedContract} - Object containing sierra (required), casm (optional), and compiler version (optional) + * @throws {Error} - If no .sierra.json file is found, multiple .sierra.json files are found, or file reading fails + * @example + * ```typescript + * // Load from directory + * const contract = contractLoader('./contracts/my_contract'); + * // contract = { sierra: {...}, casm: {...}, compiler: '2.6.0' } + * + * // Load from .sierra.json file + * const contract = contractLoader('./contracts/my_contract.sierra.json'); + * + * // Load from .casm file (will find matching .sierra.json) + * const contract = contractLoader('./contracts/my_contract.casm'); + * ``` + */ +export function contractLoader(contractPath: string): LoadedContract { + // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require + const fs = require('fs'); + // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require + const path = require('path'); + + let dirPath: string; + let specifiedSierraFile: string | undefined; + let specifiedCasmFile: string | undefined; + + // Check if the path is a file or directory + const stats = fs.statSync(contractPath); + + if (stats.isFile()) { + // If it's a file, extract the directory and remember which file was specified + dirPath = path.dirname(contractPath); + const fileName = path.basename(contractPath); + + if ( + fileName.endsWith('.sierra.json') || + (fileName.endsWith('.json') && !fileName.endsWith('.casm')) + ) { + specifiedSierraFile = fileName; + } else if (fileName.endsWith('.casm')) { + specifiedCasmFile = fileName; + } else { + throw new Error( + `Invalid file type. Expected .json, .sierra.json, or .casm file, got: ${fileName}` + ); + } + } else if (stats.isDirectory()) { + dirPath = contractPath; + } else { + throw new Error(`Path is neither a file nor a directory: ${contractPath}`); + } + + // Read all files in the directory + const files = fs.readdirSync(dirPath); + + let sierraFile: string | undefined; + let casmFile: string | undefined; + + if (specifiedSierraFile) { + // User specified a .json file - look for matching .casm + sierraFile = specifiedSierraFile; + const baseName = sierraFile.replace(/\.sierra\.json$/, '').replace(/\.json$/, ''); + casmFile = files.find((f: string) => f === `${baseName}.casm`); + } else if (specifiedCasmFile) { + // User specified a .casm file - look for matching .json + casmFile = specifiedCasmFile; + const baseName = casmFile.replace(/\.casm$/, ''); + // Try .sierra.json first, then .json + sierraFile = + files.find((f: string) => f === `${baseName}.sierra.json`) || + files.find((f: string) => f === `${baseName}.json`); + } else { + // User specified a directory - find .sierra.json files and their matching .casm + const sierraFiles = files.filter( + (f: string) => f.endsWith('.sierra.json') || (f.endsWith('.json') && !f.endsWith('.casm')) + ); + + if (sierraFiles.length === 0) { + throw new Error(`No .sierra.json file found in ${dirPath}. Sierra file is required.`); + } + + if (sierraFiles.length > 1) { + throw new Error( + `Multiple .sierra.json files found in ${dirPath}: ${sierraFiles.join(', ')}. Please specify which file to use.` + ); + } + + [sierraFile] = sierraFiles; + // At this point sierraFile is guaranteed to be defined because we checked sierraFiles.length + const baseName = sierraFile!.replace(/\.sierra\.json$/, '').replace(/\.json$/, ''); + casmFile = files.find((f: string) => f === `${baseName}.casm`); + } + + // Sierra is required + if (!sierraFile) { + throw new Error(`No .sierra.json file found in ${dirPath}. Sierra file is required.`); + } + + // Load sierra file + const sierraPath = path.join(dirPath, sierraFile); + const sierraContent = parse(fs.readFileSync(sierraPath, 'utf8')); + + const result: LoadedContract = { + sierra: sierraContent, + }; + + // Load casm if available + if (casmFile) { + const casmPath = path.join(dirPath, casmFile); + const casmContent = parse(fs.readFileSync(casmPath, 'utf8')); + result.casm = casmContent; + result.compiler = casmContent.compiler_version; + } + + return result; +} diff --git a/tsup.config.ts b/tsup.config.ts index fd0a8ab73..b7d05d246 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,9 +1,30 @@ +import path from 'path'; import { defineConfig } from 'tsup'; -export default defineConfig({ - entry: ['src/index.ts'], - sourcemap: true, - clean: true, - format: ['cjs'], - globalName: 'starknet', +export default defineConfig((options) => { + const isBrowser = options.platform === 'browser'; + + return { + entry: ['src/index.ts'], + sourcemap: true, + clean: true, + format: ['cjs'], + globalName: 'starknet', + esbuildPlugins: isBrowser + ? [ + { + name: 'replace-node-modules', + setup(build) { + // Replace contractLoaderNode with contractLoaderBrowser in browser builds + build.onResolve({ filter: /contractLoaderNode$/ }, (args) => { + const dir = path.dirname(path.resolve(args.resolveDir, args.path)); + return { + path: path.join(dir, 'contractLoaderBrowser.ts'), + }; + }); + }, + }, + ] + : [], + }; }); From 9b69257c96e166726e5e4243690262fe7d5ceec7 Mon Sep 17 00:00:00 2001 From: Toni Tabak Date: Wed, 17 Dec 2025 22:11:19 +0100 Subject: [PATCH 2/3] fix: resolve relative and absolute path --- __tests__/contract.test.ts | 5 +---- src/utils/contractLoaderNode.ts | 12 ++++++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/__tests__/contract.test.ts b/__tests__/contract.test.ts index 9e3ec2897..a5719f904 100644 --- a/__tests__/contract.test.ts +++ b/__tests__/contract.test.ts @@ -245,10 +245,7 @@ describe('contract module', () => { }); test('factory deployment of new contract with constructor arguments as already compiled calldata', async () => { - const loadedContract = contractLoader( - '/Users/tabaktoni/Work/starknet.js/__mocks__/cairo/cairo294' - ); - + const loadedContract = contractLoader('./__mocks__/cairo/cairo294'); // Ensure casm is loaded expect(loadedContract.casm).toBeDefined(); diff --git a/src/utils/contractLoaderNode.ts b/src/utils/contractLoaderNode.ts index 92516f436..c5b03ec8c 100644 --- a/src/utils/contractLoaderNode.ts +++ b/src/utils/contractLoaderNode.ts @@ -36,17 +36,21 @@ export function contractLoader(contractPath: string): LoadedContract { // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require const path = require('path'); + // Resolve relative paths to absolute paths + // This ensures consistent path handling throughout the function + const resolvedPath = path.resolve(contractPath); + let dirPath: string; let specifiedSierraFile: string | undefined; let specifiedCasmFile: string | undefined; // Check if the path is a file or directory - const stats = fs.statSync(contractPath); + const stats = fs.statSync(resolvedPath); if (stats.isFile()) { // If it's a file, extract the directory and remember which file was specified - dirPath = path.dirname(contractPath); - const fileName = path.basename(contractPath); + dirPath = path.dirname(resolvedPath); + const fileName = path.basename(resolvedPath); if ( fileName.endsWith('.sierra.json') || @@ -61,7 +65,7 @@ export function contractLoader(contractPath: string): LoadedContract { ); } } else if (stats.isDirectory()) { - dirPath = contractPath; + dirPath = resolvedPath; } else { throw new Error(`Path is neither a file nor a directory: ${contractPath}`); } From 635282e56053cc46a544fc799e98c42f975bf9e0 Mon Sep 17 00:00:00 2001 From: Toni Tabak Date: Fri, 19 Dec 2025 13:02:42 +0100 Subject: [PATCH 3/3] chore: env agnostic contract loader supp both node and browser with File API --- src/utils/contract.ts | 8 +- src/utils/contractLoader.ts | 269 +++++++++++++++++++++++++++++ src/utils/contractLoaderBrowser.ts | 20 --- src/utils/contractLoaderNode.ts | 136 --------------- tsup.config.ts | 33 +--- 5 files changed, 279 insertions(+), 187 deletions(-) create mode 100644 src/utils/contractLoader.ts delete mode 100644 src/utils/contractLoaderBrowser.ts delete mode 100644 src/utils/contractLoaderNode.ts diff --git a/src/utils/contract.ts b/src/utils/contract.ts index 1af445d4c..4483312fd 100644 --- a/src/utils/contract.ts +++ b/src/utils/contract.ts @@ -81,7 +81,7 @@ export function contractClassResponseToLegacyCompiledContract(ccr: ContractClass return { ...contract, program: decompressProgram(contract.program) } as LegacyCompiledContract; } -// Re-export LoadedContract type and contractLoader function from the Node.js-specific module -// Note: contractLoader uses Node.js fs/path APIs and is only available in Node.js environments -export type { LoadedContract } from './contractLoaderNode'; -export { contractLoader } from './contractLoaderNode'; +// Re-export LoadedContract type and contractLoader function with runtime detection +// Works in both Node.js (filesystem) and browsers (File API) +export type { LoadedContract } from './contractLoader'; +export { contractLoader, isFileSystemAvailable } from './contractLoader'; diff --git a/src/utils/contractLoader.ts b/src/utils/contractLoader.ts new file mode 100644 index 000000000..9508a618c --- /dev/null +++ b/src/utils/contractLoader.ts @@ -0,0 +1,269 @@ +import { CompiledSierra, CairoAssembly } from '../types/lib/contract/index'; +import { parse } from './json'; + +export interface LoadedContract { + sierra: CompiledSierra; + casm?: CairoAssembly; + compiler?: string; +} + +/** + * Helper function to check if filesystem access is available (Node.js environment) + * @return {boolean} - Returns true if running in Node.js with fs module available + */ +export function isFileSystemAvailable(): boolean { + try { + return typeof require !== 'undefined' && typeof require.resolve === 'function'; + } catch { + return false; + } +} + +/** + * Loads a Cairo contract from filesystem (Node.js) or File object (browser). + * + * **Node.js usage:** + * Accepts a directory path or a direct path to a .sierra.json or .casm file. + * - If a directory is provided: searches for .sierra.json and .casm files + * - If a file path is provided: loads that file and attempts to find the complementary file + * + * **Browser usage:** + * Accepts a File object (from file input or drag-and-drop). + * - Returns a Promise that resolves to LoadedContract + * - Automatically detects .sierra.json and .casm files + * - Can accept a single .sierra.json or .casm file, or multiple files + * + * @param {string | File | File[]} input - Path (Node.js) or File/File[] (browser) + * @return {LoadedContract | Promise} - Contract data (sync in Node.js, async in browser) + * @throws {Error} - If no .sierra.json file is found or file reading fails + * + * @example + * ```typescript + * // Node.js: Load from directory + * const contract = contractLoader('./contracts/my_contract'); + * + * // Node.js: Load from .sierra.json file + * const contract = contractLoader('./contracts/my_contract.sierra.json'); + * + * // Browser: Load from file input + * const fileInput = document.querySelector('input[type="file"]'); + * const contract = await contractLoader(fileInput.files[0]); + * + * // Browser: Load from multiple files + * const contract = await contractLoader([sierraFile, casmFile]); + * ``` + */ +export function contractLoader(contractPath: string): LoadedContract; +export function contractLoader(file: File): Promise; +export function contractLoader(files: File[]): Promise; +export function contractLoader( + input: string | File | File[] +): LoadedContract | Promise { + // Browser path: File or File[] + if (typeof File !== 'undefined' && (input instanceof File || Array.isArray(input))) { + return loadFromFileAPI(input); + } + + // Node.js path: string + if (typeof input === 'string') { + if (!isFileSystemAvailable()) { + throw new Error( + 'contractLoader with string paths is only available in Node.js environments. ' + + 'In browsers, please use File objects from or drag-and-drop. ' + + 'Example: await contractLoader(fileInput.files[0])' + ); + } + return loadFromFileSystem(input); + } + + throw new Error( + 'Invalid input type. Expected string (Node.js path) or File/File[] (browser File API)' + ); +} + +/** + * Load contract from Node.js filesystem (synchronous) + */ +function loadFromFileSystem(contractPath: string): LoadedContract { + // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require + const fs = require('fs'); + // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require + const path = require('path'); + + // Resolve relative paths to absolute paths + const resolvedPath = path.resolve(contractPath); + + let dirPath: string; + let specifiedSierraFile: string | undefined; + let specifiedCasmFile: string | undefined; + + // Check if the path is a file or directory + const stats = fs.statSync(resolvedPath); + + if (stats.isFile()) { + // If it's a file, extract the directory and remember which file was specified + dirPath = path.dirname(resolvedPath); + const fileName = path.basename(resolvedPath); + + if ( + fileName.endsWith('.sierra.json') || + (fileName.endsWith('.json') && !fileName.endsWith('.casm')) + ) { + specifiedSierraFile = fileName; + } else if (fileName.endsWith('.casm')) { + specifiedCasmFile = fileName; + } else { + throw new Error( + `Invalid file type. Expected .json, .sierra.json, or .casm file, got: ${fileName}` + ); + } + } else if (stats.isDirectory()) { + dirPath = resolvedPath; + } else { + throw new Error(`Path is neither a file nor a directory: ${contractPath}`); + } + + // Read all files in the directory + const files = fs.readdirSync(dirPath); + + let sierraFile: string | undefined; + let casmFile: string | undefined; + + if (specifiedSierraFile) { + // User specified a .json file - look for matching .casm + sierraFile = specifiedSierraFile; + const baseName = sierraFile.replace(/\.sierra\.json$/, '').replace(/\.json$/, ''); + casmFile = files.find((f: string) => f === `${baseName}.casm`); + } else if (specifiedCasmFile) { + // User specified a .casm file - look for matching .json + casmFile = specifiedCasmFile; + const baseName = casmFile.replace(/\.casm$/, ''); + // Try .sierra.json first, then .json + sierraFile = + files.find((f: string) => f === `${baseName}.sierra.json`) || + files.find((f: string) => f === `${baseName}.json`); + } else { + // User specified a directory - find .sierra.json files and their matching .casm + const sierraFiles = files.filter( + (f: string) => f.endsWith('.sierra.json') || (f.endsWith('.json') && !f.endsWith('.casm')) + ); + + if (sierraFiles.length === 0) { + throw new Error(`No .sierra.json file found in ${dirPath}. Sierra file is required.`); + } + + if (sierraFiles.length > 1) { + throw new Error( + `Multiple .sierra.json files found in ${dirPath}: ${sierraFiles.join(', ')}. Please specify which file to use.` + ); + } + + [sierraFile] = sierraFiles; + const baseName = sierraFile!.replace(/\.sierra\.json$/, '').replace(/\.json$/, ''); + casmFile = files.find((f: string) => f === `${baseName}.casm`); + } + + // Sierra is required + if (!sierraFile) { + throw new Error(`No .sierra.json file found in ${dirPath}. Sierra file is required.`); + } + + // Load sierra file + const sierraPath = path.join(dirPath, sierraFile); + const sierraContent = parse(fs.readFileSync(sierraPath, 'utf8')); + + const result: LoadedContract = { + sierra: sierraContent, + }; + + // Load casm if available + if (casmFile) { + const casmPath = path.join(dirPath, casmFile); + const casmContent = parse(fs.readFileSync(casmPath, 'utf8')); + result.casm = casmContent; + result.compiler = casmContent.compiler_version; + } + + return result; +} + +/** + * Load contract from browser File API (asynchronous) + */ +async function loadFromFileAPI(input: File | File[]): Promise { + const files = Array.isArray(input) ? input : [input]; + + if (files.length === 0) { + throw new Error('No files provided'); + } + + // Identify sierra and casm files + const sierraFiles = files.filter( + (file) => + file.name.endsWith('.sierra.json') || + (file.name.endsWith('.json') && !file.name.endsWith('.casm')) + ); + const casmFiles = files.filter((file) => file.name.endsWith('.casm')); + + if (sierraFiles.length > 1) { + throw new Error( + `Multiple .sierra.json files provided: ${sierraFiles.map((f) => f.name).join(', ')}. Please provide only one sierra file.` + ); + } + + if (casmFiles.length > 1) { + throw new Error( + `Multiple .casm files provided: ${casmFiles.map((f) => f.name).join(', ')}. Please provide only one casm file.` + ); + } + + const sierraFile = sierraFiles[0]; + const casmFile = casmFiles[0]; + + if (!sierraFile) { + throw new Error( + 'No .sierra.json file found in provided files. Sierra file is required. ' + + `Provided files: ${files.map((f) => f.name).join(', ')}` + ); + } + + // Read sierra file + const sierraContent = parse(await readFileAsText(sierraFile)); + + const result: LoadedContract = { + sierra: sierraContent, + }; + + // Read casm file if available + if (casmFile) { + const casmContent = parse(await readFileAsText(casmFile)); + result.casm = casmContent; + result.compiler = casmContent.compiler_version; + } + + return result; +} + +/** + * Helper to read a File object as text using FileReader + */ +function readFileAsText(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (event) => { + const content = event.target?.result; + if (typeof content === 'string') { + resolve(content); + } else { + reject(new Error(`Failed to read file ${file.name} as text`)); + } + }; + + reader.onerror = () => { + reject(new Error(`Failed to read file ${file.name}: ${reader.error?.message}`)); + }; + + reader.readAsText(file); + }); +} diff --git a/src/utils/contractLoaderBrowser.ts b/src/utils/contractLoaderBrowser.ts deleted file mode 100644 index 64c99abda..000000000 --- a/src/utils/contractLoaderBrowser.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { CairoAssembly, CompiledSierra } from '../types/lib/contract/index'; - -export interface LoadedContract { - sierra: CompiledSierra; - casm?: CairoAssembly; - compiler?: string; -} - -/** - * Browser stub for contractLoader. - * This function is not available in browser environments. - * Use it only in Node.js environments (server-side or build scripts). - */ -export function contractLoader(_contractPath: string): LoadedContract { - throw new Error( - 'contractLoader is only available in Node.js environment. ' + - 'This function requires filesystem access and cannot be used in browsers. ' + - 'Load your contracts using other methods (e.g., import, fetch) in browser environments.' - ); -} diff --git a/src/utils/contractLoaderNode.ts b/src/utils/contractLoaderNode.ts deleted file mode 100644 index c5b03ec8c..000000000 --- a/src/utils/contractLoaderNode.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { CompiledSierra, CairoAssembly } from '../types/lib/contract/index'; -import { parse } from './json'; - -export interface LoadedContract { - sierra: CompiledSierra; - casm?: CairoAssembly; - compiler?: string; -} - -/** - * Loads a Cairo contract from the filesystem. - * - * Accepts either a directory path or a direct path to a .sierra.json or .casm file. - * - If a directory is provided: searches for .sierra.json and .casm files - * - If a file path is provided: loads that file and attempts to find the complementary file - * - * @param {string} contractPath - Path to a directory or a .sierra.json/.casm file - * @return {LoadedContract} - Object containing sierra (required), casm (optional), and compiler version (optional) - * @throws {Error} - If no .sierra.json file is found, multiple .sierra.json files are found, or file reading fails - * @example - * ```typescript - * // Load from directory - * const contract = contractLoader('./contracts/my_contract'); - * // contract = { sierra: {...}, casm: {...}, compiler: '2.6.0' } - * - * // Load from .sierra.json file - * const contract = contractLoader('./contracts/my_contract.sierra.json'); - * - * // Load from .casm file (will find matching .sierra.json) - * const contract = contractLoader('./contracts/my_contract.casm'); - * ``` - */ -export function contractLoader(contractPath: string): LoadedContract { - // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require - const fs = require('fs'); - // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require - const path = require('path'); - - // Resolve relative paths to absolute paths - // This ensures consistent path handling throughout the function - const resolvedPath = path.resolve(contractPath); - - let dirPath: string; - let specifiedSierraFile: string | undefined; - let specifiedCasmFile: string | undefined; - - // Check if the path is a file or directory - const stats = fs.statSync(resolvedPath); - - if (stats.isFile()) { - // If it's a file, extract the directory and remember which file was specified - dirPath = path.dirname(resolvedPath); - const fileName = path.basename(resolvedPath); - - if ( - fileName.endsWith('.sierra.json') || - (fileName.endsWith('.json') && !fileName.endsWith('.casm')) - ) { - specifiedSierraFile = fileName; - } else if (fileName.endsWith('.casm')) { - specifiedCasmFile = fileName; - } else { - throw new Error( - `Invalid file type. Expected .json, .sierra.json, or .casm file, got: ${fileName}` - ); - } - } else if (stats.isDirectory()) { - dirPath = resolvedPath; - } else { - throw new Error(`Path is neither a file nor a directory: ${contractPath}`); - } - - // Read all files in the directory - const files = fs.readdirSync(dirPath); - - let sierraFile: string | undefined; - let casmFile: string | undefined; - - if (specifiedSierraFile) { - // User specified a .json file - look for matching .casm - sierraFile = specifiedSierraFile; - const baseName = sierraFile.replace(/\.sierra\.json$/, '').replace(/\.json$/, ''); - casmFile = files.find((f: string) => f === `${baseName}.casm`); - } else if (specifiedCasmFile) { - // User specified a .casm file - look for matching .json - casmFile = specifiedCasmFile; - const baseName = casmFile.replace(/\.casm$/, ''); - // Try .sierra.json first, then .json - sierraFile = - files.find((f: string) => f === `${baseName}.sierra.json`) || - files.find((f: string) => f === `${baseName}.json`); - } else { - // User specified a directory - find .sierra.json files and their matching .casm - const sierraFiles = files.filter( - (f: string) => f.endsWith('.sierra.json') || (f.endsWith('.json') && !f.endsWith('.casm')) - ); - - if (sierraFiles.length === 0) { - throw new Error(`No .sierra.json file found in ${dirPath}. Sierra file is required.`); - } - - if (sierraFiles.length > 1) { - throw new Error( - `Multiple .sierra.json files found in ${dirPath}: ${sierraFiles.join(', ')}. Please specify which file to use.` - ); - } - - [sierraFile] = sierraFiles; - // At this point sierraFile is guaranteed to be defined because we checked sierraFiles.length - const baseName = sierraFile!.replace(/\.sierra\.json$/, '').replace(/\.json$/, ''); - casmFile = files.find((f: string) => f === `${baseName}.casm`); - } - - // Sierra is required - if (!sierraFile) { - throw new Error(`No .sierra.json file found in ${dirPath}. Sierra file is required.`); - } - - // Load sierra file - const sierraPath = path.join(dirPath, sierraFile); - const sierraContent = parse(fs.readFileSync(sierraPath, 'utf8')); - - const result: LoadedContract = { - sierra: sierraContent, - }; - - // Load casm if available - if (casmFile) { - const casmPath = path.join(dirPath, casmFile); - const casmContent = parse(fs.readFileSync(casmPath, 'utf8')); - result.casm = casmContent; - result.compiler = casmContent.compiler_version; - } - - return result; -} diff --git a/tsup.config.ts b/tsup.config.ts index b7d05d246..fd0a8ab73 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,30 +1,9 @@ -import path from 'path'; import { defineConfig } from 'tsup'; -export default defineConfig((options) => { - const isBrowser = options.platform === 'browser'; - - return { - entry: ['src/index.ts'], - sourcemap: true, - clean: true, - format: ['cjs'], - globalName: 'starknet', - esbuildPlugins: isBrowser - ? [ - { - name: 'replace-node-modules', - setup(build) { - // Replace contractLoaderNode with contractLoaderBrowser in browser builds - build.onResolve({ filter: /contractLoaderNode$/ }, (args) => { - const dir = path.dirname(path.resolve(args.resolveDir, args.path)); - return { - path: path.join(dir, 'contractLoaderBrowser.ts'), - }; - }); - }, - }, - ] - : [], - }; +export default defineConfig({ + entry: ['src/index.ts'], + sourcemap: true, + clean: true, + format: ['cjs'], + globalName: 'starknet', });