feat: add toolbox contract shell and testkit
This commit is contained in:
245
packages/contract/src/load.ts
Normal file
245
packages/contract/src/load.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
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) };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user