62 lines
3.2 KiB
TypeScript
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; }
|
|
}
|