246 lines
6.7 KiB
TypeScript
246 lines
6.7 KiB
TypeScript
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<Response>;
|
|
|
|
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<unknown> {
|
|
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<ToolboxAppManifest> {
|
|
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<ToolboxCatalog> {
|
|
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<ResolvedToolboxCatalog> {
|
|
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<ToolboxLoadOptions, "baseUrl" | "expectedOrigin"> {
|
|
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<ToolboxContextResult> {
|
|
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) };
|
|
}
|
|
}
|