@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
JavaScript
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 };