Files
HMDesign/packages/org-chart/src/data/source.ts
T

62 lines
3.2 KiB
TypeScript

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<unknown> {
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; }
}