import { discoverToolboxCatalog, type ToolboxDiscoveryOptions, } from "./discovery.js"; import { parseToolboxApp, parseToolboxCatalog } from "./parse.js"; import { ToolboxError, type ResolvedToolboxCatalog, type ToolboxAppManifest, type ToolboxCatalog, type ToolboxContextResult, type ToolboxDiscoverySource, } from "./types.js"; import { requireSameOrigin, resolveToolboxApp, resolveWebUrl } from "./urls.js"; export type ToolboxFetch = ( input: string | URL | Request, init?: RequestInit, ) => Promise; export interface ToolboxLoadOptions { fetch?: ToolboxFetch; signal?: AbortSignal; baseUrl?: string | URL; expectedOrigin?: string | URL; } function defaultFetch(): ToolboxFetch { if (typeof globalThis.fetch !== "function") { throw new ToolboxError( "fetch-failed", "No fetch implementation is available", ); } return globalThis.fetch.bind(globalThis); } function absoluteUrl(reference: string | URL, base?: string | URL): URL { if (base !== undefined) return resolveWebUrl(reference, base); if (typeof reference !== "string" || /^[a-z][a-z\d+.-]*:/i.test(reference)) { return resolveWebUrl(reference); } if (typeof globalThis.location !== "undefined") { return resolveWebUrl(reference, globalThis.location.href); } throw new ToolboxError( "invalid-url", `A base URL is required for relative URL: ${reference}`, ); } async function fetchJson( url: URL, options: ToolboxLoadOptions, ): Promise { let response: Response; try { response = await (options.fetch ?? defaultFetch())(url, { headers: { accept: "application/json" }, redirect: "error", ...(options.signal === undefined ? {} : { signal: options.signal }), }); } catch (cause) { throw new ToolboxError("fetch-failed", `Could not fetch ${url.href}`, { cause, }); } if (typeof response.url === "string" && response.url !== "") { requireSameOrigin( resolveWebUrl(response.url), url, `Final response for ${url.href}`, ); } if (!response.ok) { throw new ToolboxError( "fetch-failed", `Could not fetch ${url.href}: HTTP ${response.status}`, ); } try { return await response.json(); } catch (cause) { throw new ToolboxError("fetch-failed", `${url.href} is not valid JSON`, { cause, }); } } export async function fetchToolboxAppManifest( manifestUrl: string | URL, options: ToolboxLoadOptions = {}, ): Promise { const url = absoluteUrl(manifestUrl, options.baseUrl); if (options.expectedOrigin !== undefined) { requireSameOrigin( url, absoluteUrl(options.expectedOrigin), "Application manifest", ); } return parseToolboxApp(await fetchJson(url, options)); } export async function fetchToolboxCatalog( catalogUrl: string | URL, options: ToolboxLoadOptions = {}, ): Promise { const url = absoluteUrl(catalogUrl, options.baseUrl); if (options.expectedOrigin !== undefined) { requireSameOrigin( url, absoluteUrl(options.expectedOrigin), "Toolbox catalog", ); } return parseToolboxCatalog(await fetchJson(url, options)); } export async function loadToolboxCatalog( catalogReference: string | URL, options: ToolboxLoadOptions = {}, ): Promise { const catalogUrl = absoluteUrl(catalogReference, options.baseUrl); const expectedOrigin = options.expectedOrigin === undefined ? catalogUrl : absoluteUrl(options.expectedOrigin); requireSameOrigin(catalogUrl, expectedOrigin, "Toolbox catalog"); const catalog = await fetchToolboxCatalog(catalogUrl, { ...options, expectedOrigin, }); const apps = await Promise.all( catalog.apps .filter((entry) => entry.enabled) .map(async (entry) => { if ("entry" in entry) { return { kind: "external" as const, enabled: entry.enabled, name: entry.name, entryUrl: resolveWebUrl(entry.entry, catalogUrl), launch: entry.launch, }; } const manifestUrl = requireSameOrigin( resolveWebUrl(entry.manifest, catalogUrl), expectedOrigin, "Catalog application manifest", ); const manifest = await fetchToolboxAppManifest(manifestUrl, { ...options, expectedOrigin, }); return { kind: "manifest" as const, enabled: entry.enabled, app: resolveToolboxApp(manifest, manifestUrl, expectedOrigin), }; }), ); const homeUrl = requireSameOrigin( resolveWebUrl(catalog.home, catalogUrl), expectedOrigin, "Toolbox home", ); return { catalog, catalogUrl, apps, homeUrl, }; } export interface ToolboxContextLoadOptions extends ToolboxDiscoveryOptions, Omit { catalogUrl?: string | URL; } function asToolboxError(error: unknown): ToolboxError { return error instanceof ToolboxError ? error : new ToolboxError( "fetch-failed", "The toolbox context could not be loaded", { cause: error, }, ); } export async function loadToolboxContext( options: ToolboxContextLoadOptions = {}, ): Promise { let source: ToolboxDiscoverySource; let catalogUrl: URL; let pageUrl: URL; if (options.catalogUrl !== undefined) { source = "explicit"; try { pageUrl = resolveWebUrl( options.location ?? (typeof globalThis.location === "undefined" ? options.catalogUrl : globalThis.location.href), ); catalogUrl = requireSameOrigin( resolveWebUrl(options.catalogUrl, pageUrl), pageUrl, "Toolbox catalog", ); } catch (error) { return { status: "error", error: asToolboxError(error) }; } } else { const discovery = discoverToolboxCatalog(options); if (discovery.status === "none") return { status: "standalone" }; if (discovery.status === "error") return discovery; source = discovery.source; catalogUrl = discovery.catalogUrl; pageUrl = resolveWebUrl( options.location ?? (typeof globalThis.location === "undefined" ? catalogUrl : globalThis.location.href), ); } try { const catalog = await loadToolboxCatalog(catalogUrl, { ...(options.fetch === undefined ? {} : { fetch: options.fetch }), ...(options.signal === undefined ? {} : { signal: options.signal }), expectedOrigin: pageUrl, }); return { status: "ready", context: { source, catalog } }; } catch (error) { return { status: "error", error: asToolboxError(error) }; } }