diff --git a/CHANGELOG.md b/CHANGELOG.md index d55ab43..a416ba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Sandbox UI support + ## [0.38.0] - 2025-10-15 ### Added diff --git a/eslint.config.js b/eslint.config.js index f00f845..b4ff818 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,6 +1,16 @@ const base = require('@ton/toolchain'); +const globals = require('globals'); module.exports = [ ...base, { ignores: ['src/executor/emulator-emscripten*', 'src/config/defaultConfig.ts', 'src/config/slimConfig.ts'] }, + { + files: ['src/jest/**/*.ts', 'src/jest/**/*.tsx'], + languageOptions: { + globals: { + ...globals.jest, + ...globals.node, + }, + }, + }, ]; diff --git a/package.json b/package.json index b549629..806b436 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@types/jest": "^30.0.0", "@types/node": "^24.1.0", "eslint": "^9.28.0", + "globals": "^16.4.0", "jest": "^30.0.5", "jest-config": "^30.0.5", "jest-environment-node": "^30.0.5", diff --git a/src/blockchain/Blockchain.ts b/src/blockchain/Blockchain.ts index 00b26f8..adf4161 100644 --- a/src/blockchain/Blockchain.ts +++ b/src/blockchain/Blockchain.ts @@ -41,6 +41,12 @@ import { MessageQueueManager } from './MessageQueueManager'; import { AsyncLock } from '../utils/AsyncLock'; import { BlockchainSnapshot } from './BlockchainSnapshot'; import { requireOptional } from '../utils/require'; +import { noop } from '../utils/noop'; +import { getOptionalEnv } from '../utils/environment'; +import { IUIConnector } from '../ui/connection/UIConnector'; +import { WebSocketConnectionOptions } from '../ui/connection/websocket/types'; +import { OneTimeWebSocketConnector } from '../ui/connection/websocket/OneTimeWebSocketConnector'; +import { IUIManager, UIManager } from '../ui/UIManager'; const CREATE_WALLETS_PREFIX = 'CREATE_WALLETS'; @@ -130,6 +136,7 @@ export function toSandboxContract(contract: OpenedContract): SandboxContra export type PendingMessage = ( | ({ type: 'message'; + callStack?: string; mode?: number; } & Message) | { @@ -174,6 +181,8 @@ export type SendMessageIterParams = MessageParams & { allowParallel?: boolean; }; +export type UIOptions = { enabled?: boolean; connector?: IUIConnector } & WebSocketConnectionOptions; + export class Blockchain { protected lock = new AsyncLock(); @@ -199,6 +208,7 @@ export class Blockchain { protected transactions: BlockchainTransaction[] = []; protected defaultQueueManager: MessageQueueManager; + protected uiManager: IUIManager; protected collectCoverage: boolean = false; protected readonly coverageTransactions: BlockchainTransaction[][] = []; @@ -323,6 +333,7 @@ export class Blockchain { storage: BlockchainStorage; meta?: ContractsMeta; autoDeployLibs?: boolean; + uiOptions?: UIOptions; }) { this.networkConfig = blockchainConfigToBase64(opts.config); this.executor = opts.executor; @@ -331,6 +342,21 @@ export class Blockchain { this.autoDeployLibs = opts.autoDeployLibs ?? false; this.defaultQueueManager = this.createQueueManager(); + this.uiManager = this.createUiManager(opts.uiOptions); + } + + protected createUiManager(opts?: UIOptions): IUIManager { + if (!opts?.enabled) { + // noop implementation + return { publishTransactions: noop }; + } + + const connector = opts.connector ?? new OneTimeWebSocketConnector(opts); + + return new UIManager(connector, { + getMeta: (address) => this.meta?.get(address), + knownContracts: () => this.storage.knownContracts(), + }); } protected createQueueManager(): MessageQueueManager { @@ -341,8 +367,11 @@ export class Blockchain { getLibs: () => this.libs, setLibs: (value: Cell | undefined) => (this.libs = value), getAutoDeployLibs: () => this.autoDeployLibs, - registerTxsForCoverage: (txs) => this.registerTxsForCoverage(txs), - addTransaction: (transaction: BlockchainTransaction) => this.transactions.push(transaction), + onTransactions: (txs) => { + this.registerTxsForCoverage(txs); + this.uiManager.publishTransactions(txs); + }, + onTransaction: (transaction: BlockchainTransaction) => this.transactions.push(transaction), }); } @@ -655,11 +684,12 @@ export class Blockchain { * Opens contract. Returns proxy that substitutes the blockchain Provider in methods starting with get and set. * * @param contract Contract to open. + * @param name Name of the contract. * * @example * const contract = blockchain.openContract(new Contract(address)); */ - openContract(contract: T) { + openContract(contract: T, name?: string) { let address: Address; let init: StateInit | undefined = undefined; @@ -677,7 +707,7 @@ export class Blockchain { init = contract.init; } - this.meta?.upsert(address, { wrapperName: contract?.constructor?.name, abi: contract.abi }); + this.meta?.upsert(address, { wrapperName: name ?? contract?.constructor?.name, abi: contract.abi }); const provider = this.provider(address, init); @@ -808,6 +838,7 @@ export class Blockchain { */ public enableCoverage(enable: boolean = true) { this.collectCoverage = enable; + this.verbosity.print = false; this.verbosity.vmLogs = 'vm_logs_verbose'; } @@ -896,6 +927,7 @@ export class Blockchain { * @param [opts.storage] Contracts storage used for blockchain. If omitted {@link LocalBlockchainStorage} is used. * @param [opts.meta] Optional contracts metadata provider. If not provided, {@link @ton/test-utils.contractsMeta} will be used to accumulate contracts metadata. * @param [opts.autoDeployLibs] Optional flag. If set to true, libraries will be collected automatically + * @param [opts.useWebsocket] Send data to websocket using `opts.connectionOptions` options. * @example * const blockchain = await Blockchain.create({ config: 'slim' }); * @@ -914,12 +946,26 @@ export class Blockchain { storage?: BlockchainStorage; meta?: ContractsMeta; autoDeployLibs?: boolean; + uiOptions?: UIOptions; }) { - return new Blockchain({ + const uiEnabled = opts?.uiOptions?.enabled ?? getOptionalEnv('SANDBOX_UI_ENABLED', 'boolean'); + + const blockchain = new Blockchain({ executor: opts?.executor ?? (await Executor.create()), storage: opts?.storage ?? new LocalBlockchainStorage(), meta: opts?.meta ?? requireOptional('@ton/test-utils')?.contractsMeta, ...opts, + uiOptions: { + enabled: uiEnabled, + ...opts?.uiOptions, + }, }); + + if (uiEnabled) { + blockchain.verbosity.print = false; + blockchain.verbosity.vmLogs = 'vm_logs_verbose'; + } + + return blockchain; } } diff --git a/src/blockchain/MessageQueueManager.ts b/src/blockchain/MessageQueueManager.ts index f24d7a9..3d22c61 100644 --- a/src/blockchain/MessageQueueManager.ts +++ b/src/blockchain/MessageQueueManager.ts @@ -27,8 +27,8 @@ export class MessageQueueManager { getLibs(): Cell | undefined; setLibs(libs: Cell | undefined): void; getAutoDeployLibs(): boolean; - registerTxsForCoverage(txs: BlockchainTransaction[]): void; - addTransaction(transaction: BlockchainTransaction): void; + onTransactions(txs: BlockchainTransaction[]): void; + onTransaction(transaction: BlockchainTransaction): void; }, ) {} @@ -99,7 +99,8 @@ export class MessageQueueManager { return result; }); - this.blockchain.registerTxsForCoverage(results); + + this.blockchain.onTransactions(results); return results; } @@ -109,9 +110,11 @@ export class MessageQueueManager { while (!done) { const message = this.messageQueue.shift()!; + let callStack: string | undefined; let tx: SmartContractTransaction; let smartContract: SmartContract; if (message.type === 'message') { + callStack = message.callStack; if (message.info.type === 'external-out') { done = this.messageQueue.length == 0; continue; @@ -119,7 +122,7 @@ export class MessageQueueManager { this.blockchain.increaseLt(); smartContract = await this.blockchain.getContract(message.info.dest); - tx = await smartContract.receiveMessage(message, params); + tx = await smartContract.receiveMessage(message, params, callStack); } else { this.blockchain.increaseLt(); smartContract = await this.blockchain.getContract(message.on); @@ -136,7 +139,7 @@ export class MessageQueueManager { }; transaction.parent?.children.push(transaction); - this.blockchain.addTransaction(transaction); + this.blockchain.onTransaction(transaction); result = transaction; done = true; @@ -163,6 +166,7 @@ export class MessageQueueManager { type: 'message', parentTransaction: transaction, mode: sendMsgActions[index]?.mode, + callStack, ...message, }); diff --git a/src/blockchain/SmartContract.ts b/src/blockchain/SmartContract.ts index 3c986a4..0412b83 100644 --- a/src/blockchain/SmartContract.ts +++ b/src/blockchain/SmartContract.ts @@ -212,6 +212,7 @@ export type SmartContractTransaction = Transaction & { blockchainLogs: string; vmLogs: string; debugLogs: string; + callStack?: string; oldStorage?: Cell; newStorage?: Cell; outActions?: OutActionExtended[]; @@ -414,7 +415,7 @@ export class SmartContract { }; } - async receiveMessage(message: Message, params?: MessageParams) { + async receiveMessage(message: Message, params?: MessageParams, callStack?: string) { const args: RunTransactionArgs = { ...this.createCommonArgs(params), message: beginCell().store(storeMessage(message)).endCell(), @@ -425,14 +426,14 @@ export class SmartContract { const { uninitialized, debugInfo } = debugContext.getDebugInfo(this.account); if (debugInfo !== undefined) { const executor = await this.blockchain.getDebuggerExecutor(); - return await this.runCommon(() => debugContext.debugTransaction(executor, args, debugInfo)); + return await this.runCommon(() => debugContext.debugTransaction(executor, args, debugInfo), callStack); } else if (uninitialized) { // eslint-disable-next-line no-console console.log('Debugging uninitialized accounts is unsupported in debugger beta'); } } - return await this.runCommon(() => this.blockchain.executor.runTransaction(args)); + return await this.runCommon(() => this.blockchain.executor.runTransaction(args), callStack); } async runTickTock(which: TickOrTock, params?: MessageParams) { @@ -444,7 +445,10 @@ export class SmartContract { ); } - protected async runCommon(run: () => Promise): Promise { + protected async runCommon( + run: () => Promise, + callStack?: string, + ): Promise { let oldStorage: Cell | undefined = undefined; if (this.blockchain.recordStorage && this.account.account?.storage.state.type === 'active') { oldStorage = this.account.account?.storage.state.state.data ?? undefined; @@ -498,6 +502,7 @@ export class SmartContract { blockchainLogs: res.logs, vmLogs: res.result.vmLog, debugLogs: res.debugLogs, + callStack, oldStorage, newStorage, outActions, diff --git a/src/jest/uiSetup.ts b/src/jest/uiSetup.ts new file mode 100644 index 0000000..fd8d446 --- /dev/null +++ b/src/jest/uiSetup.ts @@ -0,0 +1,46 @@ +import { randomUUID } from 'crypto'; + +import { Blockchain } from '../blockchain/Blockchain'; +import { ManagedWebSocketConnector } from '../ui/connection/websocket/ManagedWebSocketConnector'; + +let websocketConnector: ManagedWebSocketConnector | undefined; + +beforeAll(() => { + const originalCreate = Blockchain.create.bind(Blockchain); + + Blockchain.create = async (...args): Promise => { + let [opts, ...otherArgs] = args; + + if (!opts?.uiOptions?.connector) { + if (!websocketConnector) { + websocketConnector = new ManagedWebSocketConnector(opts?.uiOptions); + // eslint-disable-next-line no-console + console.log('Connecting to websocket connector...'); + await websocketConnector.connect(); + } + + opts = { + ...opts, + uiOptions: { + ...opts?.uiOptions, + enabled: true, + connector: websocketConnector, + }, + }; + } + + return await originalCreate(opts, ...otherArgs); + }; +}); + +beforeEach(() => { + expect.setState({ testId: randomUUID() }); +}); + +afterEach(() => { + expect.setState({ testId: undefined }); +}); + +afterAll(() => { + websocketConnector?.disconnect(); +}); diff --git a/src/ui/UIManager.ts b/src/ui/UIManager.ts new file mode 100644 index 0000000..b1b6129 --- /dev/null +++ b/src/ui/UIManager.ts @@ -0,0 +1,51 @@ +import { Address } from '@ton/core'; + +import { BlockchainTransaction } from '../blockchain/Blockchain'; +import { ContractMeta } from '../meta/ContractsMeta'; +import { SmartContract } from '../blockchain/SmartContract'; +import { serializeTransactions, serializeContracts, TestInfo, MessageTestData } from './protocol'; +import { IUIConnector } from './connection/UIConnector'; + +// eslint-disable-next-line no-undef +declare const expect: jest.Expect | undefined; + +export interface IUIManager { + publishTransactions(transactions: BlockchainTransaction[]): void; +} + +export class UIManager implements IUIManager { + constructor( + private readonly connector: IUIConnector, + private readonly blockchain: { + getMeta(address: Address): ContractMeta | undefined; + knownContracts(): SmartContract[]; + }, + ) {} + + publishTransactions(txs: BlockchainTransaction[]) { + let testInfo = this.getTestInfo(); + const transactions = serializeTransactions(txs); + const knownContracts = this.blockchain.knownContracts(); + const contracts = serializeContracts( + knownContracts.map((contract) => ({ contract, meta: this.blockchain.getMeta(contract.address) })), + ); + + this.connector.send( + JSON.stringify({ type: 'test-data', testInfo, transactions, contracts } satisfies MessageTestData), + ); + } + + private getTestInfo(): TestInfo | undefined { + if (expect === undefined) { + return; + } + + const expectState = expect.getState(); + + return { + id: expectState.testId, + name: expectState.currentTestName, + path: expectState.testPath, + }; + } +} diff --git a/src/ui/connection/UIConnector.ts b/src/ui/connection/UIConnector.ts new file mode 100644 index 0000000..1f064b4 --- /dev/null +++ b/src/ui/connection/UIConnector.ts @@ -0,0 +1,3 @@ +export interface IUIConnector { + send(data: string): void; +} diff --git a/src/ui/connection/websocket/ManagedWebSocketConnector.ts b/src/ui/connection/websocket/ManagedWebSocketConnector.ts new file mode 100644 index 0000000..a4cce32 --- /dev/null +++ b/src/ui/connection/websocket/ManagedWebSocketConnector.ts @@ -0,0 +1,45 @@ +import { getOptionalEnv } from '../../../utils/environment'; +import { WebSocketConnectionOptions } from './types'; +import { IUIConnector } from '../UIConnector'; +import { CONNECT_ERROR_MESSAGE, DEFAULT_HOST, DEFAULT_PORT } from './constants'; + +export class ManagedWebSocketConnector implements IUIConnector { + private readonly websocketAddress: string; + + ws: WebSocket | undefined; + + constructor({ wsPort = DEFAULT_PORT, wsHost = DEFAULT_HOST }: WebSocketConnectionOptions = {}) { + this.websocketAddress = getOptionalEnv('SANDBOX_WEBSOCKET_ADDR') ?? `ws://${wsHost}:${wsPort}`; + } + + async connect() { + try { + this.ws = await new Promise((resolve, reject) => { + const ws = new WebSocket(this.websocketAddress); + ws.addEventListener('open', () => resolve(ws), { once: true }); + ws.addEventListener( + 'error', + (err) => { + ws.close(); + reject(err); + }, + { once: true }, + ); + }); + } catch (err) { + // eslint-disable-next-line no-console + console.warn(CONNECT_ERROR_MESSAGE, err); + } + } + + disconnect() { + this.ws?.close(); + this.ws = undefined; + } + + send(data: string): void { + if (this?.ws?.readyState === WebSocket.OPEN) { + this.ws.send(data); + } + } +} diff --git a/src/ui/connection/websocket/OneTimeWebSocketConnector.ts b/src/ui/connection/websocket/OneTimeWebSocketConnector.ts new file mode 100644 index 0000000..ded7ea3 --- /dev/null +++ b/src/ui/connection/websocket/OneTimeWebSocketConnector.ts @@ -0,0 +1,42 @@ +import { getOptionalEnv } from '../../../utils/environment'; +import { WebSocketConnectionOptions } from './types'; +import { IUIConnector } from '../UIConnector'; +import { CONNECT_ERROR_MESSAGE, DEFAULT_HOST, DEFAULT_PORT } from './constants'; + +export class OneTimeWebSocketConnector implements IUIConnector { + private readonly websocketAddress: string; + + constructor({ wsPort = DEFAULT_PORT, wsHost = DEFAULT_HOST }: WebSocketConnectionOptions) { + this.websocketAddress = getOptionalEnv('SANDBOX_WEBSOCKET_ADDR') ?? `ws://${wsHost}:${wsPort}`; + } + + send(data: string): void { + // This solution, which requires a reconnection for each send, may seem inefficient. + // However, when Jest is not used, it’s unclear when the connection should be closed. + // Until the connection is closed, the Node process will not terminate, and Jest will issue + // a warning, while the test will continue to wait for the connection to close. + // + // This solution avoids that problem, as the connection is closed immediately after sending. + // Meanwhile, the reconnection time is short enough to be unnoticeable during tests. + const ws = new WebSocket(this.websocketAddress); + + ws.addEventListener( + 'open', + () => { + ws.send(data); + ws.close(); + }, + { once: true }, + ); + + ws.addEventListener( + 'error', + (err) => { + // eslint-disable-next-line no-console + console.warn(CONNECT_ERROR_MESSAGE, err); + ws.close(); + }, + { once: true }, + ); + } +} diff --git a/src/ui/connection/websocket/constants.ts b/src/ui/connection/websocket/constants.ts new file mode 100644 index 0000000..eecc3de --- /dev/null +++ b/src/ui/connection/websocket/constants.ts @@ -0,0 +1,4 @@ +export const DEFAULT_PORT = 7743; +export const DEFAULT_HOST = 'localhost'; +export const CONNECT_ERROR_MESSAGE = + 'Unable to connect to websocket server. Make sure the port and host match the sandbox server or VS Code settings. You can set the WebSocket address globally with `SANDBOX_WEBSOCKET_ADDR=ws://localhost:7743` or via `Blockchain.create({ uiOptions: { enabled: true, wsHost: "localhost", wsPort: 7743 } })`.'; diff --git a/src/ui/connection/websocket/types.ts b/src/ui/connection/websocket/types.ts new file mode 100644 index 0000000..2ded3ab --- /dev/null +++ b/src/ui/connection/websocket/types.ts @@ -0,0 +1,4 @@ +export type WebSocketConnectionOptions = { + wsPort?: number; + wsHost?: string; +}; diff --git a/src/ui/protocol.md b/src/ui/protocol.md new file mode 100644 index 0000000..47cabcd --- /dev/null +++ b/src/ui/protocol.md @@ -0,0 +1,79 @@ +# UI Protocol + +The **UI Protocol** defines the communication interface between the testing environment and the Sandbox UI. +When `SANDBOX_UI_ENABLED` is set, test data and blockchain state updates are sent to the UI for visualization and debugging. +This allows developers to inspect transactions, contract states, and logs in real time. + +## Connection + +### Websocket +- **Port**: 7743 (default) +- **Host**: localhost (default) +- **Environment Variable**: `SANDBOX_WEBSOCKET_ADDR` (overrides default) + +## Message Types + +### 1. Test Data Message +```typescript +{ + "type": "test-data", + "testInfo": TestInfo | undefined, + "transactions": RawTransactionsInfo, + "contracts": RawContractData[] +} +``` + +## Data Formats + +### TestInfo +```typescript +{ + "id": string | undefined, + "name": string | undefined, + "path": string | undefined +} +``` + +### RawTransactionInfo +```typescript +{ + "transaction": string, // Hex-encoded BOC transaction + "blockchainLogs": string, // Blockchain execution logs + "vmLogs": string, // VM execution logs + "debugLogs": string, // Debug output logs + "code": string | undefined, // Contract code (hex) + "sourceMap": object | undefined, // Source mapping + "contractName": string | undefined, // Contract name + "parentId": string | undefined, // Parent transaction ID + "childrenIds": string[], // Child transaction IDs + "oldStorage": string | undefined, // Old storage state (hex) + "newStorage": string | undefined, // New storage state (hex) + "callStack": string | undefined // Function call stack +} +``` + +### RawTransactionsInfo +``` +{ + "transactions": RawTransactionInfo[] +} +``` + +### RawContractData +``` +{ + "address": string, // Contract address + "meta": ContractMeta | undefined, // Contract metadata + "stateInit": string | undefined, // State init (hex BOC) + "account": string // Account data (hex BOC) +} +``` + +### ContractMeta +``` +{ + "wrapperName": string | undefined; + "abi": ContractABI | undefined; + "treasurySeed": string | undefined; +} +``` diff --git a/src/ui/protocol.ts b/src/ui/protocol.ts new file mode 100644 index 0000000..4f43c1d --- /dev/null +++ b/src/ui/protocol.ts @@ -0,0 +1,98 @@ +import { beginCell, storeShardAccount, storeStateInit, storeTransaction, Transaction } from '@ton/core'; + +import { BlockchainTransaction } from '../blockchain/Blockchain'; +import { ContractMeta } from '../meta/ContractsMeta'; +import { SmartContract } from '../blockchain/SmartContract'; + +declare const hexBrand: unique symbol; +export type HexString = string & { readonly [hexBrand]: true }; + +export type MessageTestData = { + readonly type: 'test-data'; + readonly testInfo: TestInfo | undefined; + readonly transactions: RawTransactionsInfo; + readonly contracts: readonly RawContractData[]; +}; + +export type Message = MessageTestData; + +export type RawTransactionsInfo = { + readonly transactions: readonly RawTransactionInfo[]; +}; + +export type RawTransactionInfo = { + readonly transaction: string; + readonly blockchainLogs: string; + readonly vmLogs: string; + readonly debugLogs: string; + readonly code: string | undefined; + readonly sourceMap: object | undefined; + readonly contractName: string | undefined; + readonly parentId: string | undefined; + readonly childrenIds: string[]; + readonly oldStorage: HexString | undefined; + readonly newStorage: HexString | undefined; + readonly callStack: string | undefined; +}; + +export function serializeTransactions(transactions: BlockchainTransaction[]): RawTransactionsInfo { + return { + transactions: transactions.map((t): RawTransactionInfo => { + const tx = beginCell() + .store(storeTransaction(t as Transaction)) + .endCell() + .toBoc() + .toString('hex'); + + return { + transaction: tx, + blockchainLogs: t.blockchainLogs, + vmLogs: t.vmLogs, + debugLogs: t.debugLogs, + code: undefined, + sourceMap: undefined, + contractName: undefined, + parentId: t.parent?.lt.toString(), + childrenIds: t.children?.map((c) => c?.lt?.toString()), + oldStorage: t.oldStorage?.toBoc().toString('hex') as HexString | undefined, + newStorage: t.newStorage?.toBoc().toString('hex') as HexString | undefined, + callStack: t.callStack, + }; + }), + }; +} + +export type RawContractData = { + readonly address: string; + readonly meta: ContractMeta | undefined; + readonly stateInit: HexString | undefined; + readonly account: HexString | undefined; +}; + +export function serializeContracts(contracts: { contract: SmartContract; meta?: ContractMeta }[]): RawContractData[] { + return contracts.map(({ contract, meta }): RawContractData => { + const state = contract.accountState; + const stateInit = beginCell(); + if (state?.type === 'active') { + stateInit.store(storeStateInit(state.state)); + } + const stateInitCell = stateInit.asCell(); + + const account = contract.account; + const accountCell = beginCell().store(storeShardAccount(account)).endCell(); + + return { + address: contract.address.toString(), + meta, + stateInit: + stateInitCell.bits.length === 0 ? undefined : (stateInitCell.toBoc().toString('hex') as HexString), + account: accountCell.toBoc().toString('hex') as HexString, + }; + }); +} + +export type TestInfo = { + readonly id?: string; + readonly name?: string; + readonly path?: string; +}; diff --git a/src/utils/environment.ts b/src/utils/environment.ts index 41ab001..3286fa7 100644 --- a/src/utils/environment.ts +++ b/src/utils/environment.ts @@ -2,3 +2,25 @@ export function isBrowser(): boolean { // eslint-disable-next-line no-undef return typeof window !== 'undefined' && typeof window.document !== 'undefined'; } + +const converters = { + boolean: (value: string | undefined) => { + if (!value) return; + + return value.toLowerCase() === 'true'; + }, + string: (value: string | undefined) => value, +} as const; + +type Converters = typeof converters; + +export function getOptionalEnv( + envName: string, + envType?: EnvType, +): ReturnType | undefined { + if (!process || !process.env) return undefined as ReturnType; + + const converter = envType ? converters[envType] : converters.string; + + return converter(process.env[envName]) as ReturnType; +} diff --git a/src/utils/noop.ts b/src/utils/noop.ts new file mode 100644 index 0000000..89966dd --- /dev/null +++ b/src/utils/noop.ts @@ -0,0 +1 @@ +export function noop(): void {} diff --git a/yarn.lock b/yarn.lock index dfb0947..01617ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1381,6 +1381,7 @@ __metadata: chalk: "npm:^4.1.2" eslint: "npm:^9.28.0" fflate: "npm:^0.8.2" + globals: "npm:^16.4.0" jest: "npm:^30.0.5" jest-config: "npm:^30.0.5" jest-environment-node: "npm:^30.0.5" @@ -3697,6 +3698,13 @@ __metadata: languageName: node linkType: hard +"globals@npm:^16.4.0": + version: 16.4.0 + resolution: "globals@npm:16.4.0" + checksum: 10/1627a9f42fb4c82d7af6a0c8b6cd616e00110908304d5f1ddcdf325998f3aed45a4b29d8a1e47870f328817805263e31e4f1673f00022b9c2b210552767921cf + languageName: node + linkType: hard + "globalthis@npm:^1.0.4": version: 1.0.4 resolution: "globalthis@npm:1.0.4"