import { DataSettings } from './lib/types.js' import { ArraySchema, JSONSchema, ObjectSchema, transformSchema } from './schema.js' export type DataHandler = (settings: DataSettings) => Partial | Promise export type RequestConfigurator = (resource: RequestInfo, options?: RequestInit) => Promise // DROP THIS // Store registered datasources in a global variable BYOCDatasources for external access // On the clientside the datasource registry is shared via global variable, on server it doesnt to avoid breaking next.js export const registeredDatasources: Record = typeof window != 'undefined' ? (window.BYOCDatasources ||= {}) : {} /** * Registers a custom datasource with the provided function and schema. * * @param handler - A function that returns the DataSettings settings. * @param options - Options for the datasource. Includes the datasource id, schema, and other * metadata. * @param {string} options.id - Unique identifier of the datasource. * @param {string} [options.name] - (Optional) Internal name of a datasource. Defaults to the id. * @param {string} [options.sample] - (Optional) Sample data for the datasource (alternative to schema). * @param {string} [options.type] - (Optional) JSON Schema type (array or object). * @param {string} [options.properties] - (Optional) JSON Schema properties definition. * @param {string} [options.schema] - (Optional) Whole JSON Schema definition. * @returns Void. * @example * * // HTTP-based datasource, described by schema * * registerDatasource( * () => ({ * url: 'https://api.sampleapis.com/wines/reds' * }), * { * id: 'http-and-schema', * name: 'Wines via HTTP', * description: 'List of red wines fetched by HTTP, each with `wine`, `price` and `id` property', * type: 'array', * properties: { * wine: { type: 'string' }, * price: { type: 'string' }, * id: { type: 'number' } * } * } * ) * * @example * * // When datasource is registered with ID of a datasource that exist in the library, it can adjust the data settings of the * original request. * * registerDatasource( * // settings will contain DataSettings as specified via UI in Components app * (settings) => ({ * ...settings, * params: { * // add ?page=2 parameter to the original URL * ...settings.params, * page: 2 * }, * headers: { * // add Authorization header in addition to original headers * ...settings.headers, * Authorization: 'Bearer token' * } * }), * { * // ID of a datasource as created in UI (can be visible in the address bar URL) to be extended * // No other options are specified in this case. * id: 'aBcDaaa23a' * } * ) * * @example * * import { promises as fs } from 'fs' * // Async handlers supposed to return data itself instead of DataSettings * * registerDatasource( * async () => { * return JSON.parse(await fs.readFile('wines.json', 'utf-8')) * }, * { * id: 'file-and-sample', * name: 'Wines from JSON file', * description: 'JSON file read and parsed from file (no HTTP request is made), with sample data', * sample: [ * { wine: 'Emporda 2012', id: 1, price: '$250' }, * { wine: 'Pêra-Manca Tinto 1990', id: 2, price: '$312' } * ] * } * ) * */ export function registerDatasource( handler: DataHandler, options: { sample?: any schema?: ArraySchema | ObjectSchema id: string name?: string description?: string title?: string properties?: JSONSchema['properties'] type?: 'array' | 'object' } ) { if (typeof handler !== 'function') { throw new Error( `The first argument of registerDatasource must be a function returning DataSettings or Promise of data` ) } if (!options.id) { throw new Error(`Missing 'id' property in input`) } const idRegex = /^[a-zA-Z0-9-_]+$/ if (!idRegex.test(options.id)) { throw new Error( `Invalid 'id' property in input. 'id' should only contain alphanumeric characters, hyphens, and underscores.` ) } //if (getDatasource(options.id)?.handler != null) { // throw new Error(`Datasource with id ${options.id} already registered`) //} registeredDatasources[options.id] = { ...normalizeDatasourceOptions(options), handler } setRegistrationCallback() } export type DatasourceOptionsInput = Parameters[1] /** * Returns the registered datasource with the provided id. * * @param id - The id of the registered datasource. * @returns The registered datasource. */ export function getDatasource(id: RegisteredDatasource['id']) { return registeredDatasources[id] } /** * Normalizes the datasource options. If the schema is not provided, it will be derived from the properties. * * @param datasourceOptions - The datasource options. * @returns The normalized datasource options. */ function normalizeDatasourceOptions(datasourceOptions: DatasourceOptionsInput) { const { id, name, title, properties, sample, schema, description = null, type = 'object' } = datasourceOptions return { id, description, sample, name: name || title || id, handler: ((settings) => settings) as DataHandler, schema: schema || properties ? transformSchema({ ...(schema || { properties, type }), title: schema?.title || title || name }) : undefined } } export type RegisteredDatasource = ReturnType var registrationCallback: ReturnType var datasourceRegistrationAcknowledged = false var datasourceRegistrationRetryCount = 0 var datasourceAckListenerAdded = false // Extended retry configuration to ensure reliable datasource registration // Total retry window: ~30 seconds (150 retries × 200ms) // This addresses race conditions where parent window listener may not be ready immediately const DATASOURCE_MAX_RETRY_ATTEMPTS = 150 const DATASOURCE_RETRY_INTERVAL = 200 // ms /** * Set up acknowledgment listener for datasource registration. * This listener receives confirmation from the parent window that datasources were registered. */ function setupDatasourceAckListener() { if (typeof window === 'undefined' || datasourceAckListenerAdded) return datasourceAckListenerAdded = true window.addEventListener('message', (event) => { try { const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data if (data.action === 'register-datasources-ack') { datasourceRegistrationAcknowledged = true datasourceRegistrationRetryCount = 0 clearTimeout(registrationCallback) } } catch (e) { // Ignore parse errors from other messages } }) } /** * Sends datasource registration to parent window with retry logic. * Retries up to DATASOURCE_MAX_RETRY_ATTEMPTS times until acknowledgment is received. */ function setRegistrationCallback() { clearTimeout(registrationCallback) if (typeof window !== 'undefined' && window.parent !== window) { // Set up acknowledgment listener on first call setupDatasourceAckListener() // Reset acknowledgment state when new registration is triggered datasourceRegistrationAcknowledged = false datasourceRegistrationRetryCount = 0 const sendRegistration = () => { // Stop if already acknowledged or max retries reached if (datasourceRegistrationAcknowledged) { return } if (datasourceRegistrationRetryCount >= DATASOURCE_MAX_RETRY_ATTEMPTS) { // Max retries reached, stop trying but don't log error to avoid noise return } // Send datasources to parent window window.parent?.postMessage( JSON.stringify({ action: 'register-datasources', data: Object.values(registeredDatasources) }), '*' ) datasourceRegistrationRetryCount++ // Schedule next retry if not acknowledged if (!datasourceRegistrationAcknowledged && datasourceRegistrationRetryCount < DATASOURCE_MAX_RETRY_ATTEMPTS) { registrationCallback = setTimeout(sendRegistration, DATASOURCE_RETRY_INTERVAL) } } // Initial delay before first send (maintains original behavior) registrationCallback = setTimeout(sendRegistration, 30) } } setRegistrationCallback() /** * For given datasource id and original settings, either returns adjusted settings or a promise of data. */ export function customizeDataSettings( id: RegisteredDatasource['id'], settings: DataSettings ): Partial | Promise { const datasource = registeredDatasources[id] if (datasource?.handler) { return datasource.handler(settings) } return settings } declare global { interface Window { BYOCDatasources: Record } }