feat: add toolbox contract shell and testkit

This commit is contained in:
2026-07-20 17:53:56 +02:00
commit ce9bddb218
53 changed files with 8950 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
import {
TOOLBOX_META_NAME,
TOOLBOX_QUERY_PARAMETER,
ToolboxError,
type ToolboxDiscovery,
} from "./types.js";
import { requireSameOrigin, resolveWebUrl } from "./urls.js";
interface MetaElementLike {
getAttribute(name: string): string | null;
}
interface DocumentLike {
querySelector(selector: string): MetaElementLike | null;
}
export interface ToolboxDiscoveryOptions {
location?: string | URL;
document?: DocumentLike;
queryParameter?: string;
metaName?: string;
}
function browserLocation(): string | undefined {
return typeof globalThis.location === "undefined"
? undefined
: globalThis.location.href;
}
function browserDocument(): DocumentLike | undefined {
return typeof globalThis.document === "undefined"
? undefined
: globalThis.document;
}
export function discoverToolboxCatalog(
options: ToolboxDiscoveryOptions = {},
): ToolboxDiscovery {
const locationReference = options.location ?? browserLocation();
if (locationReference === undefined) return { status: "none" };
let location: URL;
try {
location = resolveWebUrl(locationReference);
} catch (error) {
return {
status: "error",
error:
error instanceof ToolboxError
? error
: new ToolboxError("invalid-url", "The page URL is invalid", {
cause: error,
}),
};
}
const parameter = options.queryParameter ?? TOOLBOX_QUERY_PARAMETER;
const queryValue = location.searchParams.get(parameter);
const document = options.document ?? browserDocument();
let metaValue: string | null | undefined;
try {
const metaName = (options.metaName ?? TOOLBOX_META_NAME)
.replaceAll("\\", "\\\\")
.replaceAll('"', '\\"');
metaValue = document
?.querySelector(`meta[name="${metaName}"]`)
?.getAttribute("content");
} catch (error) {
return {
status: "error",
error: new ToolboxError("invalid-url", "Toolbox meta discovery failed", {
cause: error,
}),
};
}
const candidate = queryValue ?? metaValue;
if (candidate === null || candidate === undefined) return { status: "none" };
if (candidate.trim().length === 0) {
return {
status: "error",
error: new ToolboxError(
"invalid-url",
`The ${queryValue === null ? "meta" : "query"} toolbox URL is empty`,
),
};
}
try {
const catalogUrl = requireSameOrigin(
resolveWebUrl(candidate, location),
location,
"Discovered toolbox catalog",
);
return {
status: "found",
source: queryValue === null ? "meta" : "query",
catalogUrl,
};
} catch (error) {
return {
status: "error",
error:
error instanceof ToolboxError
? error
: new ToolboxError("invalid-url", "The toolbox URL is invalid", {
cause: error,
}),
};
}
}

View File

@@ -0,0 +1,60 @@
export {
TOOLBOX_META_NAME,
TOOLBOX_QUERY_PARAMETER,
TOOLBOX_SCHEMA_VERSION,
ToolboxError,
ToolboxValidationError,
} from "./types.js";
export type {
ResolvedToolboxAction,
ResolvedToolboxApp,
ResolvedToolboxCatalog,
ResolvedToolboxCatalogEntry,
ResolvedToolboxCatalogExternalEntry,
ResolvedToolboxCatalogManifestEntry,
ResolvedToolboxPrivacy,
ToolboxAction,
ToolboxAppManifest,
ToolboxCatalog,
ToolboxCatalogEntry,
ToolboxCatalogExternalEntry,
ToolboxCatalogManifestEntry,
ToolboxCatalogTheme,
ToolboxContext,
ToolboxContextResult,
ToolboxDiscovery,
ToolboxDiscoverySource,
ToolboxErrorCode,
ToolboxIntegration,
ToolboxLaunchMode,
ToolboxPrivacy,
ToolboxRequirements,
ToolboxSource,
} from "./types.js";
export {
createContextualLink,
contextualizeToolboxLink,
requireSameOrigin,
resolveToolboxApp,
resolveWebUrl,
} from "./urls.js";
export type { ContextualLinkOptions } from "./urls.js";
export {
defineToolboxApp,
defineToolboxCatalog,
parseToolboxApp,
parseToolboxCatalog,
} from "./parse.js";
export { discoverToolboxCatalog } from "./discovery.js";
export type { ToolboxDiscoveryOptions } from "./discovery.js";
export {
fetchToolboxAppManifest,
fetchToolboxCatalog,
loadToolboxCatalog,
loadToolboxContext,
} from "./load.js";
export type {
ToolboxContextLoadOptions,
ToolboxFetch,
ToolboxLoadOptions,
} from "./load.js";

View 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) };
}
}

View File

@@ -0,0 +1,528 @@
import {
TOOLBOX_SCHEMA_VERSION,
ToolboxValidationError,
type ToolboxAction,
type ToolboxAppManifest,
type ToolboxCatalog,
type ToolboxCatalogEntry,
type ToolboxCatalogExternalEntry,
type ToolboxCatalogManifestEntry,
type ToolboxCatalogTheme,
type ToolboxIntegration,
type ToolboxLaunchMode,
type ToolboxPrivacy,
type ToolboxRequirements,
type ToolboxSource,
} from "./types.js";
type UnknownRecord = Record<string, unknown>;
const ID_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
const WEB_PROTOCOLS = new Set(["http:", "https:"]);
const REFERENCE_BASE = "https://toolbox.invalid/";
function recordAt(
value: unknown,
path: string,
issues: string[],
): UnknownRecord {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
issues.push(`${path} must be an object`);
return {};
}
return value as UnknownRecord;
}
function stringAt(
object: UnknownRecord,
key: string,
path: string,
issues: string[],
options: { optional?: boolean; allowEmpty?: boolean } = {},
): string | undefined {
if (!(key in object) && options.optional) return undefined;
const value = object[key];
if (typeof value !== "string") {
issues.push(`${path}.${key} must be a string`);
return undefined;
}
if (!options.allowEmpty && value.trim().length === 0) {
issues.push(`${path}.${key} must not be empty`);
return undefined;
}
return value;
}
function booleanAt(
object: UnknownRecord,
key: string,
path: string,
issues: string[],
optional = false,
): boolean | undefined {
if (!(key in object) && optional) return undefined;
const value = object[key];
if (typeof value !== "boolean") {
issues.push(`${path}.${key} must be a boolean`);
return undefined;
}
return value;
}
function enumAt<const T extends string>(
object: UnknownRecord,
key: string,
allowed: readonly T[],
path: string,
issues: string[],
): T | undefined {
const value = stringAt(object, key, path, issues);
if (value === undefined) return undefined;
if (!allowed.includes(value as T)) {
issues.push(`${path}.${key} must be one of ${allowed.join(", ")}`);
return undefined;
}
return value as T;
}
function idAt(
object: UnknownRecord,
key: string,
path: string,
issues: string[],
): string | undefined {
const value = stringAt(object, key, path, issues);
if (value !== undefined && !ID_PATTERN.test(value)) {
issues.push(`${path}.${key} must be a lowercase toolbox id`);
return undefined;
}
return value;
}
function referenceAt(
object: UnknownRecord,
key: string,
path: string,
issues: string[],
optional = false,
): string | undefined {
const value = stringAt(object, key, path, issues, { optional });
if (value === undefined) return undefined;
if (value.includes("\\")) {
issues.push(`${path}.${key} must use URL path separators`);
return undefined;
}
try {
const url = new URL(value, REFERENCE_BASE);
if (!WEB_PROTOCOLS.has(url.protocol) || url.username || url.password) {
issues.push(`${path}.${key} must be an HTTP(S) URL reference`);
return undefined;
}
} catch {
issues.push(`${path}.${key} must be a valid URL reference`);
return undefined;
}
return value;
}
function absoluteReferenceAt(
object: UnknownRecord,
key: string,
path: string,
issues: string[],
): string | undefined {
const value = referenceAt(object, key, path, issues);
if (value === undefined) return undefined;
try {
const url = new URL(value);
if (!WEB_PROTOCOLS.has(url.protocol) || url.username || url.password) {
throw new Error("Unsupported URL");
}
} catch {
issues.push(`${path}.${key} must be an absolute HTTP(S) URL`);
return undefined;
}
return value;
}
function schemaVersionAt(
object: UnknownRecord,
path: string,
issues: string[],
): void {
if (object.schemaVersion !== TOOLBOX_SCHEMA_VERSION) {
issues.push(`${path}.schemaVersion must be ${TOOLBOX_SCHEMA_VERSION}`);
}
}
function stringListAt(
object: UnknownRecord,
key: string,
path: string,
issues: string[],
): readonly string[] {
const value = object[key];
if (!Array.isArray(value)) {
issues.push(`${path}.${key} must be an array`);
return [];
}
const seen = new Set<string>();
return value.flatMap((item, index) => {
if (typeof item !== "string" || item.trim().length === 0) {
issues.push(`${path}.${key}[${index}] must be a non-empty string`);
return [];
}
if (seen.has(item)) {
issues.push(`${path}.${key}[${index}] must be unique`);
return [];
}
seen.add(item);
return [item];
});
}
function parsePrivacy(
value: unknown,
path: string,
issues: string[],
): ToolboxPrivacy | undefined {
const object = recordAt(value, path, issues);
const processing = enumAt(
object,
"processing",
["local", "remote", "mixed"] as const,
path,
issues,
);
const fileUploads = booleanAt(object, "fileUploads", path, issues);
const telemetry = booleanAt(object, "telemetry", path, issues);
const label = stringAt(object, "label", path, issues, { optional: true });
const url = referenceAt(object, "url", path, issues, true);
if (
processing === undefined ||
fileUploads === undefined ||
telemetry === undefined
) {
return undefined;
}
return {
processing,
fileUploads,
telemetry,
...(label === undefined ? {} : { label }),
...(url === undefined ? {} : { url }),
};
}
function parseIntegration(
value: unknown,
path: string,
issues: string[],
): ToolboxIntegration | undefined {
const object = recordAt(value, path, issues);
if (object.contextVersion !== 1) {
issues.push(`${path}.contextVersion must be 1`);
}
const launchModes = object.launchModes;
const modes: ToolboxLaunchMode[] = [];
if (!Array.isArray(launchModes) || launchModes.length === 0) {
issues.push(`${path}.launchModes must be a non-empty array`);
} else {
const seen = new Set<string>();
launchModes.forEach((mode, index) => {
if (mode !== "navigate" && mode !== "new-tab") {
issues.push(`${path}.launchModes[${index}] is invalid`);
} else if (seen.has(mode)) {
issues.push(`${path}.launchModes[${index}] must be unique`);
} else {
seen.add(mode);
modes.push(mode);
}
});
}
const embedding = enumAt(
object,
"embedding",
["unsupported", "supported"] as const,
path,
issues,
);
return object.contextVersion === 1 &&
modes.length > 0 &&
embedding !== undefined
? { contextVersion: 1, launchModes: modes, embedding }
: undefined;
}
function parseRequirements(
value: unknown,
path: string,
issues: string[],
): ToolboxRequirements | undefined {
const object = recordAt(value, path, issues);
const secureContext = booleanAt(object, "secureContext", path, issues);
const workers = booleanAt(object, "workers", path, issues);
const indexedDb = booleanAt(object, "indexedDb", path, issues);
const crossOriginIsolated = booleanAt(
object,
"crossOriginIsolated",
path,
issues,
);
const topLevelContext = booleanAt(
object,
"topLevelContext",
path,
issues,
true,
);
if (
secureContext === undefined ||
workers === undefined ||
indexedDb === undefined ||
crossOriginIsolated === undefined
) {
return undefined;
}
return {
secureContext,
workers,
indexedDb,
crossOriginIsolated,
...(topLevelContext === undefined ? {} : { topLevelContext }),
};
}
function parseSource(
value: unknown,
path: string,
issues: string[],
): ToolboxSource | undefined {
const object = recordAt(value, path, issues);
const repository = absoluteReferenceAt(object, "repository", path, issues);
const license = stringAt(object, "license", path, issues);
return repository === undefined || license === undefined
? undefined
: { repository, license };
}
function parseActions(
value: unknown,
path: string,
issues: string[],
): readonly ToolboxAction[] {
if (!Array.isArray(value)) {
issues.push(`${path} must be an array`);
return [];
}
const ids = new Set<string>();
return value.flatMap((item, index) => {
const itemPath = `${path}[${index}]`;
const object = recordAt(item, itemPath, issues);
const id = idAt(object, "id", itemPath, issues);
const label = stringAt(object, "label", itemPath, issues);
const url = referenceAt(object, "url", itemPath, issues);
if (id !== undefined) {
if (ids.has(id)) issues.push(`${itemPath}.id must be unique`);
ids.add(id);
}
return id === undefined || label === undefined || url === undefined
? []
: [{ id, label, url }];
});
}
function parseAssets(
value: unknown,
path: string,
issues: string[],
): readonly string[] {
if (!Array.isArray(value)) {
issues.push(`${path} must be an array`);
return [];
}
const seen = new Set<string>();
return value.flatMap((item, index) => {
const holder = { value: item };
const reference = referenceAt(holder, "value", `${path}[${index}]`, issues);
if (reference === undefined) return [];
if (seen.has(reference)) {
issues.push(`${path}[${index}] must be unique`);
return [];
}
seen.add(reference);
return [reference];
});
}
export function parseToolboxApp(value: unknown): ToolboxAppManifest {
const issues: string[] = [];
const object = recordAt(value, "$", issues);
schemaVersionAt(object, "$", issues);
const id = idAt(object, "id", "$", issues);
const name = stringAt(object, "name", "$", issues);
const version = stringAt(object, "version", "$", issues);
const description = stringAt(object, "description", "$", issues, {
allowEmpty: true,
});
const entry = referenceAt(object, "entry", "$", issues);
const icon = referenceAt(object, "icon", "$", issues);
const categories = stringListAt(object, "categories", "$", issues);
const tags = stringListAt(object, "tags", "$", issues);
const integration = parseIntegration(
object.integration,
"$.integration",
issues,
);
const requirements = parseRequirements(
object.requirements,
"$.requirements",
issues,
);
const privacy = parsePrivacy(object.privacy, "$.privacy", issues);
const source =
"source" in object
? parseSource(object.source, "$.source", issues)
: undefined;
const actions =
"actions" in object
? parseActions(object.actions, "$.actions", issues)
: undefined;
const assets =
"assets" in object
? parseAssets(object.assets, "$.assets", issues)
: undefined;
if (
issues.length > 0 ||
id === undefined ||
name === undefined ||
version === undefined ||
description === undefined ||
entry === undefined ||
icon === undefined ||
integration === undefined ||
requirements === undefined ||
privacy === undefined
) {
throw new ToolboxValidationError("invalid-app", issues);
}
return {
schemaVersion: TOOLBOX_SCHEMA_VERSION,
id,
name,
version,
description,
entry,
icon,
categories,
tags,
integration,
requirements,
privacy,
...(source === undefined ? {} : { source }),
...(actions === undefined ? {} : { actions }),
...(assets === undefined ? {} : { assets }),
};
}
function parseCatalogEntry(
value: unknown,
path: string,
issues: string[],
): ToolboxCatalogEntry | undefined {
const object = recordAt(value, path, issues);
const enabled = booleanAt(object, "enabled", path, issues);
if ("manifest" in object) {
const manifest = referenceAt(object, "manifest", path, issues);
if ("entry" in object) {
issues.push(`${path} cannot contain both manifest and entry`);
}
return manifest === undefined || enabled === undefined
? undefined
: ({ manifest, enabled } satisfies ToolboxCatalogManifestEntry);
}
const name = stringAt(object, "name", path, issues);
const entry = referenceAt(object, "entry", path, issues);
const launch = enumAt(
object,
"launch",
["navigate", "new-tab"] as const,
path,
issues,
);
return name === undefined ||
entry === undefined ||
launch === undefined ||
enabled === undefined
? undefined
: ({ name, entry, launch, enabled } satisfies ToolboxCatalogExternalEntry);
}
function parseTheme(
value: unknown,
path: string,
issues: string[],
): ToolboxCatalogTheme | undefined {
const object = recordAt(value, path, issues);
const mode = enumAt(
object,
"mode",
["light", "dark", "system"] as const,
path,
issues,
);
const brand = stringAt(object, "brand", path, issues);
return mode === undefined || brand === undefined
? undefined
: { mode, brand };
}
export function parseToolboxCatalog(value: unknown): ToolboxCatalog {
const issues: string[] = [];
const object = recordAt(value, "$", issues);
schemaVersionAt(object, "$", issues);
const id = idAt(object, "id", "$", issues);
const name = stringAt(object, "name", "$", issues);
const home = referenceAt(object, "home", "$", issues);
const theme = parseTheme(object.theme, "$.theme", issues);
const appsValue = object.apps;
let apps: readonly ToolboxCatalogEntry[] = [];
if (!Array.isArray(appsValue)) {
issues.push("$.apps must be an array");
} else {
apps = appsValue.flatMap((entry, index) => {
const parsed = parseCatalogEntry(entry, `$.apps[${index}]`, issues);
return parsed === undefined ? [] : [parsed];
});
}
if (
issues.length > 0 ||
id === undefined ||
name === undefined ||
home === undefined ||
theme === undefined
) {
throw new ToolboxValidationError("invalid-catalog", issues);
}
return {
schemaVersion: TOOLBOX_SCHEMA_VERSION,
id,
name,
home,
theme,
apps,
};
}
export function defineToolboxApp<const T extends ToolboxAppManifest>(
app: T,
): T {
return app;
}
export function defineToolboxCatalog<const T extends ToolboxCatalog>(
catalog: T,
): T {
return catalog;
}

View File

@@ -0,0 +1,178 @@
export const TOOLBOX_SCHEMA_VERSION = 1 as const;
export const TOOLBOX_QUERY_PARAMETER = "toolbox" as const;
export const TOOLBOX_META_NAME = "toolbox" as const;
export interface ToolboxAction {
id: string;
label: string;
url: string;
}
export interface ToolboxPrivacy {
processing: "local" | "remote" | "mixed";
fileUploads: boolean;
telemetry: boolean;
label?: string;
url?: string;
}
export type ToolboxLaunchMode = "navigate" | "new-tab";
export interface ToolboxIntegration {
contextVersion: 1;
launchModes: readonly ToolboxLaunchMode[];
embedding: "unsupported" | "supported";
}
export interface ToolboxRequirements {
secureContext: boolean;
workers: boolean;
indexedDb: boolean;
crossOriginIsolated: boolean;
topLevelContext?: boolean;
}
export interface ToolboxSource {
repository: string;
license: string;
}
export interface ToolboxAppManifest {
schemaVersion: typeof TOOLBOX_SCHEMA_VERSION;
id: string;
name: string;
version: string;
description: string;
entry: string;
icon: string;
categories: readonly string[];
tags: readonly string[];
integration: ToolboxIntegration;
requirements: ToolboxRequirements;
privacy: ToolboxPrivacy;
source?: ToolboxSource;
actions?: readonly ToolboxAction[];
assets?: readonly string[];
}
export interface ToolboxCatalogManifestEntry {
manifest: string;
enabled: boolean;
}
export interface ToolboxCatalogExternalEntry {
name: string;
entry: string;
launch: ToolboxLaunchMode;
enabled: boolean;
}
export type ToolboxCatalogEntry =
ToolboxCatalogManifestEntry | ToolboxCatalogExternalEntry;
export interface ToolboxCatalogTheme {
mode: "light" | "dark" | "system";
brand: string;
}
export interface ToolboxCatalog {
schemaVersion: typeof TOOLBOX_SCHEMA_VERSION;
id: string;
name: string;
home: string;
theme: ToolboxCatalogTheme;
apps: readonly ToolboxCatalogEntry[];
}
export interface ResolvedToolboxAction extends Omit<ToolboxAction, "url"> {
url: URL;
}
export interface ResolvedToolboxPrivacy extends Omit<ToolboxPrivacy, "url"> {
url?: URL;
}
export interface ResolvedToolboxApp {
manifest: ToolboxAppManifest;
manifestUrl: URL;
entryUrl: URL;
actions: readonly ResolvedToolboxAction[];
assetUrls: readonly URL[];
iconUrl: URL;
privacy: ResolvedToolboxPrivacy;
}
export interface ResolvedToolboxCatalogManifestEntry {
kind: "manifest";
enabled: boolean;
app: ResolvedToolboxApp;
}
export interface ResolvedToolboxCatalogExternalEntry {
kind: "external";
enabled: boolean;
name: string;
entryUrl: URL;
launch: ToolboxLaunchMode;
}
export type ResolvedToolboxCatalogEntry =
ResolvedToolboxCatalogManifestEntry | ResolvedToolboxCatalogExternalEntry;
export interface ResolvedToolboxCatalog {
catalog: ToolboxCatalog;
catalogUrl: URL;
apps: readonly ResolvedToolboxCatalogEntry[];
homeUrl: URL;
}
export type ToolboxDiscoverySource = "query" | "meta" | "explicit";
export interface ToolboxContext {
source: ToolboxDiscoverySource;
catalog: ResolvedToolboxCatalog;
}
export type ToolboxErrorCode =
| "invalid-app"
| "invalid-catalog"
| "invalid-url"
| "cross-origin"
| "fetch-failed";
export class ToolboxError extends Error {
readonly code: ToolboxErrorCode;
constructor(code: ToolboxErrorCode, message: string, options?: ErrorOptions) {
super(message, options);
this.name = "ToolboxError";
this.code = code;
}
}
export class ToolboxValidationError extends ToolboxError {
readonly issues: readonly string[];
constructor(
code: Extract<ToolboxErrorCode, "invalid-app" | "invalid-catalog">,
issues: readonly string[],
) {
super(code, `Invalid toolbox document: ${issues.join("; ")}`);
this.name = "ToolboxValidationError";
this.issues = issues;
}
}
export type ToolboxDiscovery =
| { status: "none" }
| {
status: "found";
source: Exclude<ToolboxDiscoverySource, "explicit">;
catalogUrl: URL;
}
| { status: "error"; error: ToolboxError };
export type ToolboxContextResult =
| { status: "standalone" }
| { status: "ready"; context: ToolboxContext }
| { status: "error"; error: ToolboxError };

View File

@@ -0,0 +1,131 @@
import {
TOOLBOX_QUERY_PARAMETER,
ToolboxError,
type ResolvedToolboxAction,
type ResolvedToolboxApp,
type ResolvedToolboxPrivacy,
type ToolboxAppManifest,
} from "./types.js";
const WEB_PROTOCOLS = new Set(["http:", "https:"]);
export function resolveWebUrl(
reference: string | URL,
base?: string | URL,
): URL {
let url: URL;
try {
url = base === undefined ? new URL(reference) : new URL(reference, base);
} catch (cause) {
throw new ToolboxError("invalid-url", `Invalid URL: ${String(reference)}`, {
cause,
});
}
if (!WEB_PROTOCOLS.has(url.protocol) || url.username || url.password) {
throw new ToolboxError(
"invalid-url",
`Only credential-free HTTP(S) URLs are supported: ${url.href}`,
);
}
return url;
}
export function requireSameOrigin(
url: URL,
expected: URL,
label = "Toolbox URL",
): URL {
if (url.origin !== expected.origin) {
throw new ToolboxError(
"cross-origin",
`${label} must share the page origin (${expected.origin})`,
);
}
return url;
}
export function resolveToolboxApp(
manifest: ToolboxAppManifest,
manifestUrl: string | URL,
expectedOrigin?: string | URL,
): ResolvedToolboxApp {
const resolvedManifestUrl = resolveWebUrl(manifestUrl);
const origin =
expectedOrigin === undefined
? resolvedManifestUrl
: resolveWebUrl(expectedOrigin);
requireSameOrigin(resolvedManifestUrl, origin, "Application manifest");
const localUrl = (reference: string, label: string): URL =>
requireSameOrigin(
resolveWebUrl(reference, resolvedManifestUrl),
origin,
label,
);
const externalUrl = (reference: string): URL =>
resolveWebUrl(reference, resolvedManifestUrl);
const actions: readonly ResolvedToolboxAction[] = (
manifest.actions ?? []
).map((action) => ({
...action,
url: externalUrl(action.url),
}));
const privacy: ResolvedToolboxPrivacy = {
processing: manifest.privacy.processing,
fileUploads: manifest.privacy.fileUploads,
telemetry: manifest.privacy.telemetry,
...(manifest.privacy.label === undefined
? {}
: { label: manifest.privacy.label }),
...(manifest.privacy.url === undefined
? {}
: { url: externalUrl(manifest.privacy.url) }),
};
return {
manifest,
manifestUrl: resolvedManifestUrl,
entryUrl: localUrl(manifest.entry, "Application entry"),
actions,
assetUrls: (manifest.assets ?? []).map((asset) =>
localUrl(asset, "Application asset"),
),
iconUrl: localUrl(manifest.icon, "Application icon"),
privacy,
};
}
export interface ContextualLinkOptions {
location?: string | URL;
parameter?: string;
}
export function contextualizeToolboxLink(
target: string | URL,
catalog: string | URL,
options: ContextualLinkOptions = {},
): string {
const location = resolveWebUrl(
options.location ??
(typeof globalThis.location === "undefined"
? "http://localhost/"
: globalThis.location.href),
);
const targetUrl = resolveWebUrl(target, location);
if (targetUrl.origin !== location.origin) return targetUrl.href;
const catalogUrl = requireSameOrigin(
resolveWebUrl(catalog, location),
location,
"Toolbox catalog",
);
targetUrl.searchParams.set(
options.parameter ?? TOOLBOX_QUERY_PARAMETER,
catalogUrl.href,
);
return targetUrl.href;
}
export const createContextualLink = contextualizeToolboxLink;