UNPKG

@persona/layout-editor-remote-module

Version:

Shared manifest format, Vite plugin, and host/iframe bridge for remotely-defined dashboard layout modules in Persona dashboard apps.

219 lines (215 loc) • 7.46 kB
import { z } from "zod"; //#region src/bridge/origin.ts const dashboardHostOriginPattern = /^https:\/\/([a-z0-9-]+\.)*withpersona(-staging)?\.com$/; const localHostOriginPattern = /^http:\/\/(?:localhost|127\.0\.0\.1):\d+$/; function personaDashboardHostOrigins(origin) { return dashboardHostOriginPattern.test(origin) || localHostOriginPattern.test(origin); } function resolveAllowedHostOrigins(allowedHostOrigins) { if (allowedHostOrigins === void 0) return personaDashboardHostOrigins; if (typeof allowedHostOrigins === "function") return allowedHostOrigins; return (origin) => allowedHostOrigins.some((allowedOrigin) => typeof allowedOrigin === "string" ? allowedOrigin === origin : allowedOrigin.test(origin)); } function candidateParentOrigin() { if (typeof window === "undefined") return; try { const ancestorOrigin = window.location.ancestorOrigins?.[0]; if (ancestorOrigin) return ancestorOrigin; } catch {} try { return document.referrer ? new URL(document.referrer).origin : void 0; } catch { return; } } function assertNotSelfOrigin(moduleOrigin) { if (typeof window !== "undefined" && moduleOrigin === window.location.origin) throw new Error("Remote module origin must not match the host origin"); } //#endregion //#region src/schema.ts /** * Mirrors the host's layout grid column count; the enum schemas below must * match the host's values. */ const MAX_GRID_COLUMNS = 12; const layoutTypeSchema = z.enum([ "account", "case", "transaction" ]); const moduleCategorySchema = z.enum([ "build_your_own", "components", "management", "related_records" ]); const moduleBadgeSchema = z.enum([ "beta", "deprecated", "improved", "new" ]); /** * A manifest `key` is embedded verbatim into a persisted module identity * string (`${sourceId}/${key}`) and used in DOM ids and analytics — restrict it * to a safe, stable charset up front rather than discovering the hard way that * some producer emitted spaces or slashes. */ const moduleKeySchema = z.string().min(1).regex(/^[a-z0-9_]+$/, "key must be lowercase alphanumeric with underscores only"); const gridSizeSchema = z.object({ height: z.number().int().min(1), width: z.number().int().min(1).max(MAX_GRID_COLUMNS) }); /** * The module's main view is a page URL that the host iframes. */ const remoteDeliverySchema = z.object({ document: z.string().min(1) }); const remoteEntrySchema = z.object({ render: remoteDeliverySchema }); /** * Loose check shared by `configSchema` and `uiConfigSchema` — this validates * the manifest envelope, not the arbitrary object either one carries. Both * are trusted structurally (an object) and used for their stated purpose * (JSON Schema validation, RJSF ui hints) by the consumer, not re-validated * here. */ const looseObjectSchema = z.record(z.string(), z.unknown()); const remoteModuleRegistrationManifestV1Schema = z.object({ allowAddingToLayout: z.boolean(), allowDynamicHeight: z.boolean().optional(), badge: moduleBadgeSchema.optional(), category: moduleCategorySchema.optional(), categoryCustom: z.string().trim().min(1).max(80).optional(), configSchema: looseObjectSchema.optional(), defaultConfig: z.partialRecord(layoutTypeSchema, looseObjectSchema).optional(), defaultSize: z.partialRecord(layoutTypeSchema, gridSizeSchema), descriptionShort: z.string().min(1), displayName: z.string().min(1), icon: z.string().min(1).optional(), internalUsersOnly: z.boolean().optional(), key: moduleKeySchema, layouts: z.array(layoutTypeSchema).default([]), manifestVersion: z.literal("v1"), remote: remoteEntrySchema, uiConfigSchema: looseObjectSchema.optional() }).refine((manifest) => manifest.layouts.every((layout) => manifest.defaultSize[layout]), { message: "defaultSize must have an entry for every layout in `layouts`", path: ["defaultSize"] }).refine((manifest) => manifest.layouts.length === 0 || Object.keys(manifest.defaultConfig ?? {}).every((layout) => manifest.layouts.includes(layout)), { message: "defaultConfig has an entry for a layout not in `layouts`", path: ["defaultConfig"] }); //#endregion //#region src/bridge/protocol.ts const SOURCE = "persona-layout-module"; const PROTOCOL_VERSION = 1; const jsonSchema = z.lazy(() => z.union([ z.boolean(), z.null(), z.number(), z.string(), z.array(jsonSchema), z.record(z.string(), jsonSchema) ])); const envelopeSchema = { source: z.literal(SOURCE), protocolVersion: z.literal(PROTOCOL_VERSION) }; const requestIdSchema = z.string().min(1); const variableMapSchema = z.record(z.string(), z.union([z.string(), z.number()])); const readySchema = z.object({ ...envelopeSchema, type: z.literal("ready"), capabilities: z.object({ loaded: z.boolean().optional() }).optional() }); const loadedSchema = z.object({ ...envelopeSchema, type: z.literal("loaded") }); const requestDataSchema = z.object({ ...envelopeSchema, type: z.literal("request-data"), requestId: requestIdSchema }); const setHeightSchema = z.object({ ...envelopeSchema, type: z.literal("set-height"), height: z.number() }); const renderErrorSchema = z.object({ ...envelopeSchema, type: z.literal("render-error"), message: z.string() }); const resolveDataPathSchema = z.object({ ...envelopeSchema, type: z.literal("resolve-data-path"), requestId: requestIdSchema, dataPath: z.string(), variableMap: variableMapSchema.optional() }); /** * Accepts layout types added after this release, so a module should handle a `layoutType` it * doesn't know. */ const contextLayoutTypeSchema = z.union([layoutTypeSchema, z.string()]); /** * Runtime facts about where the module is rendered, owned by the host and not * derivable by the module itself. Distinct from `data`, which is the module's * persisted config. */ const remoteModuleContextSchema = z.object({ layoutType: contextLayoutTypeSchema, layoutObjectId: z.string().nullable(), isReadOnly: z.boolean(), isInEditor: z.boolean(), isUsingStubData: z.boolean(), dynamicHeight: z.boolean().optional() }); const moduleToHostMessageSchema = z.discriminatedUnion("type", [ readySchema, loadedSchema, requestDataSchema, setHeightSchema, renderErrorSchema, resolveDataPathSchema ]); const initSchema = z.object({ ...envelopeSchema, type: z.literal("init"), data: jsonSchema, context: remoteModuleContextSchema }); const dataSchema = z.object({ ...envelopeSchema, type: z.literal("data"), requestId: requestIdSchema.optional(), data: jsonSchema }); const contextSchema = z.object({ ...envelopeSchema, type: z.literal("context"), context: remoteModuleContextSchema }); const dataPathResultSchema = z.object({ ...envelopeSchema, type: z.literal("data-path-result"), requestId: requestIdSchema, result: jsonSchema.optional(), error: z.string().optional() }).refine(({ result, error }) => result === void 0 !== (error === void 0)); const hostToModuleMessageSchema = z.discriminatedUnion("type", [ initSchema, dataSchema, contextSchema, dataPathResultSchema ]); function parseModuleToHostMessage(data) { const parsed = moduleToHostMessageSchema.safeParse(data); return parsed.success ? parsed.data : null; } function parseHostToModuleMessage(data) { const parsed = hostToModuleMessageSchema.safeParse(data); return parsed.success ? parsed.data : null; } //#endregion export { assertNotSelfOrigin as a, resolveAllowedHostOrigins as c, parseModuleToHostMessage as i, SOURCE as n, candidateParentOrigin as o, parseHostToModuleMessage as r, personaDashboardHostOrigins as s, PROTOCOL_VERSION as t };