feat: add toolbox contract shell and testkit
This commit is contained in:
110
packages/contract/src/discovery.ts
Normal file
110
packages/contract/src/discovery.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
60
packages/contract/src/index.ts
Normal file
60
packages/contract/src/index.ts
Normal 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";
|
||||
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) };
|
||||
}
|
||||
}
|
||||
528
packages/contract/src/parse.ts
Normal file
528
packages/contract/src/parse.ts
Normal 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;
|
||||
}
|
||||
178
packages/contract/src/types.ts
Normal file
178
packages/contract/src/types.ts
Normal 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 };
|
||||
131
packages/contract/src/urls.ts
Normal file
131
packages/contract/src/urls.ts
Normal 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;
|
||||
Reference in New Issue
Block a user