import { snapshotOrgChartDataset } from './snapshot.js'; import type { OrgChartDataSource } from './types.js'; export type OrgChartSourceErrorCode = 'SOURCE_LOAD' | 'SOURCE_PARSE' | 'SOURCE_ABORT' | 'SOURCE_URL' | 'SOURCE_SNAPSHOT'; export class OrgChartSourceError extends Error { readonly path = 'dataSource'; constructor(readonly code: OrgChartSourceErrorCode, message: string) { super(message); this.name = 'OrgChartSourceError'; } } export interface JsonOrgChartDataSourceOptions { fetch?: typeof globalThis.fetch } /** Inline JSON is captured at construction; URL sources load a fresh snapshot on each call. */ export class JsonOrgChartDataSource implements OrgChartDataSource { private readonly inline: unknown; private readonly url: string | undefined; private readonly fetcher: typeof globalThis.fetch | undefined; constructor(input: unknown, options: JsonOrgChartDataSourceOptions = {}) { // Browser-native fetch may require the global Window receiver. Retaining it as // an instance property must not accidentally call it with this source as `this`. this.fetcher = options.fetch ?? globalThis.fetch?.bind(globalThis); if (typeof input === 'string' || input instanceof URL) { const url = String(input); if (!safeSourceUrl(url)) throw new OrgChartSourceError('SOURCE_URL', 'Data source URL must use HTTP(S) or a relative JSON path.'); this.url = url; } else { try { this.inline = snapshotOrgChartDataset(input); } catch { throw new OrgChartSourceError('SOURCE_SNAPSHOT', 'Data source input must be cloneable JSON.'); } } } async load(options: { signal?: AbortSignal } = {}): Promise { const signal = options.signal; const aborted = () => { if (signal?.aborted) throw new OrgChartSourceError('SOURCE_ABORT', 'Data source request was cancelled.'); }; aborted(); if (this.url === undefined) return snapshotOrgChartDataset(this.inline); if (!this.fetcher) throw new OrgChartSourceError('SOURCE_LOAD', 'No fetch implementation is available.'); let response: Response; try { response = await this.fetcher(this.url, { signal }); } catch { aborted(); throw new OrgChartSourceError('SOURCE_LOAD', 'Data source could not be loaded.'); } aborted(); if (!response.ok) throw new OrgChartSourceError('SOURCE_LOAD', 'Data source returned an unsuccessful response.'); let payload: unknown; try { payload = await response.json(); } catch { aborted(); throw new OrgChartSourceError('SOURCE_PARSE', 'Data source response is not valid JSON.'); } aborted(); try { return snapshotOrgChartDataset(payload); } catch { throw new OrgChartSourceError('SOURCE_SNAPSHOT', 'Data source response must be cloneable JSON.'); } } } function safeSourceUrl(value: string): boolean { if (!value.trim() || /[\s\\<>\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/.test(value) || value.startsWith('//')) return false; if (!/^[a-z][a-z\d+.-]*:/i.test(value)) return !/%(?:0[0-9a-f]|1[0-9a-f]|7f|5c)/i.test(value); try { const parsed = new URL(value); return ['http:', 'https:'].includes(parsed.protocol) && !parsed.username && !parsed.password; } catch { return false; } }