Build reusable React component hosting and typed fragment composition

This commit is contained in:
Timothy J. Aveni
2026-09-22 16:46:33 -07:00
parent a8869e4545
commit b8571d054b
4 changed files with 65 additions and 4 deletions
+7
View File
@@ -2799,6 +2799,13 @@ const compileCapabilityResourceSourceInternal = (
revision: lowerPackage(state, context, options.source), revision: lowerPackage(state, context, options.source),
}; };
if (resource.revision.modules?.length) { if (resource.revision.modules?.length) {
resource.revision.clientPackages = Object.fromEntries(
imports.flatMap((entry) => {
if (entry.kind !== "package") return [];
const imported = options.environment?.packages?.get(entry.binding);
return imported ? [[entry.binding, imported.revisionId]] : [];
}),
);
resource.revision.clientInterfaces = Object.fromEntries( resource.revision.clientInterfaces = Object.fromEntries(
imports.flatMap((entry) => { imports.flatMap((entry) => {
if (entry.kind !== "interface") return []; if (entry.kind !== "interface") return [];
+2
View File
@@ -324,6 +324,8 @@ export interface PackageRevision {
modules?: PackageModule[]; modules?: PackageModule[];
/** Closed interface imports exposed to this package's checked component bindings. */ /** Closed interface imports exposed to this package's checked component bindings. */
clientInterfaces?: Record<string, InterfaceRevisionId>; clientInterfaces?: Record<string, InterfaceRevisionId>;
/** Explicit package imports supplying module selections, never the whole workspace. */
clientPackages?: Record<string, PackageRevisionId>;
controllers?: PackageController[]; controllers?: PackageController[];
queries?: import("../query/types.js").QueryDeclaration[]; queries?: import("../query/types.js").QueryDeclaration[];
checkedQueries?: import("../query/types.js").CheckedQuery[]; checkedQueries?: import("../query/types.js").CheckedQuery[];
+21 -4
View File
@@ -99,6 +99,7 @@ export function linkPackageQuery(plan: ExecutionPlan, contract: PublicQueryContr
export function generateInterfaceClientBindings( export function generateInterfaceClientBindings(
imports: Readonly<Record<string, InterfaceRevision>>, imports: Readonly<Record<string, InterfaceRevision>>,
runtimeModule = "@quixos/camino-replica-core", runtimeModule = "@quixos/camino-replica-core",
moduleProps?: (interfaceId: string, memberId: string) => string,
): string { ): string {
const entries = Object.entries(imports).map(([alias, iface]) => { const entries = Object.entries(imports).map(([alias, iface]) => {
if (iface.template) if (iface.template)
@@ -108,6 +109,10 @@ export function generateInterfaceClientBindings(
"Specialize the interface before generating a client", "Specialize the interface before generating a client",
); );
const members = iface.members.map((member) => { const members = iface.members.map((member) => {
if (member.kind === "module" && member.subject?.kind === "interface") {
const subject = member.subject.interfaceRevisionId;
return `${JSON.stringify(member.displayName)}: defineModuleMember<${JSON.stringify(subject)}, ${moduleProps?.(iface.revisionId, member.id) ?? "{}"}>(${JSON.stringify({ interface: iface.revisionId, member: member.id, subject, contract: member.contract })})`;
}
const operations = member.operations const operations = member.operations
.filter((op) => op.mode === "call") .filter((op) => op.mode === "call")
.map( .map(
@@ -118,7 +123,7 @@ export function generateInterfaceClientBindings(
}); });
return `${JSON.stringify(alias)}: {${members.join(",\n")}}`; return `${JSON.stringify(alias)}: {${members.join(",\n")}}`;
}); });
return `// Generated from imported interface contracts. Regenerate; do not edit.\nimport { defineInterfaceOperation, type PreparedChange, type ManagedReference } from ${JSON.stringify(runtimeModule)};\nexport const interfaces = {${entries.join(",\n")}} as const;\n`; return `// Generated from imported interface contracts. Regenerate; do not edit.\nimport { defineModuleMember, defineInterfaceOperation, type PreparedChange, type ManagedReference } from ${JSON.stringify(runtimeModule)};\nexport const interfaces = {${entries.join(",\n")}} as const;\n`;
} }
export function generatePackageQueryClientBindings( export function generatePackageQueryClientBindings(
@@ -203,8 +208,10 @@ export function generateComponentClientBindings(plan: ExecutionPlan, packageRevi
Object.entries(pkg.clientInterfaces ?? {}).map(([alias, id]) => [alias, find(id)]), Object.entries(pkg.clientInterfaces ?? {}).map(([alias, id]) => [alias, find(id)]),
); );
const subjects = Object.fromEntries(components.map((component) => [component.displayName, find(component.subject)])); const subjects = Object.fromEntries(components.map((component) => [component.displayName, find(component.subject)]));
const interfaces = generateInterfaceClientBindings(imports, "@quixos/camino-react"); const props = (iface: string, member: string) =>
const subjectBindings = generateInterfaceClientBindings(subjects, "@quixos/camino-react") `ExtraModuleProps<${JSON.stringify(JSON.stringify([iface, member]))}>`;
const interfaces = generateInterfaceClientBindings(imports, "@quixos/camino-react", props);
const subjectBindings = generateInterfaceClientBindings(subjects, "@quixos/camino-react", props)
.split("\n") .split("\n")
.filter((line) => !line.startsWith("import ")) .filter((line) => !line.startsWith("import "))
.join("\n") .join("\n")
@@ -219,10 +226,20 @@ export function generateComponentClientBindings(plan: ExecutionPlan, packageRevi
.join("\n"); .join("\n");
return ( return (
interfaces + interfaces +
'\nimport {definePackageQuery,definePackageFragment,type ComponentType,type ComponentProps} from "@quixos/camino-react";\n' + '\nimport {createObjectRenderer,definePackageQuery,definePackageFragment,type ComponentType,type ComponentProps} from "@quixos/camino-react";\n' +
subjectBindings + subjectBindings +
"\n" + "\n" +
queries + queries +
`\nexport const providers = {${Object.entries(pkg.clientPackages ?? {})
.map(([alias, id]) => {
const imported = plan.definition.workspace.packageImports.find((entry) => entry.revisionId === id);
if (!imported) throw new ExecutionPlanError("UNINSTALLED_PACKAGE", id, "Missing imported module provider");
return `${JSON.stringify(alias)}:{${(imported.modules ?? []).map((module) => `${JSON.stringify(module.displayName)}:${JSON.stringify({ packageRevision: id, id: module.id })}`).join(",")}}`;
})
.join(",")}} as const;\n` +
`\nexport const renderers = {${Object.entries(imports)
.map(([name, iface]) => `${JSON.stringify(name)}:createObjectRenderer(${JSON.stringify(iface.revisionId)})`)
.join(",\n")}} as const;\n` +
"\nexport interface ModuleProps {}\n" + "\nexport interface ModuleProps {}\n" +
"export type ExtraModuleProps<K extends string> = K extends keyof ModuleProps ? ModuleProps[K] : {};\n" + "export type ExtraModuleProps<K extends string> = K extends keyof ModuleProps ? ModuleProps[K] : {};\n" +
"export interface Modules {\n" + "export interface Modules {\n" +
+35
View File
@@ -29,6 +29,13 @@ export interface ClientContractCatalog {
readonly queries: readonly PublicQueryContract[]; readonly queries: readonly PublicQueryContract[];
readonly components: readonly InstalledComponent[]; readonly components: readonly InstalledComponent[];
readonly presentations: readonly { id: string; subject: string; packageRevision: string; component: string }[]; readonly presentations: readonly { id: string; subject: string; packageRevision: string; component: string }[];
readonly moduleBindings: readonly {
atom: string;
interface: string;
member: string;
packageRevision: string;
id: string;
}[];
} }
/** Explicit projection prevents accidental spreading of filesystem roots or private bindings. */ /** Explicit projection prevents accidental spreading of filesystem roots or private bindings. */
export function clientContractCatalog( export function clientContractCatalog(
@@ -68,6 +75,17 @@ export function clientContractCatalog(
(pkg.checkedQueries ?? []).map((q) => publicQueryContract(pkg, q.declaration.id)), (pkg.checkedQueries ?? []).map((q) => publicQueryContract(pkg, q.declaration.id)),
), ),
components: installation.components.filter((c) => requestedPackages.includes(c.packageRevision)), components: installation.components.filter((c) => requestedPackages.includes(c.packageRevision)),
moduleBindings: plan.definition.workspace.conformances.flatMap((c) =>
(c.moduleBindings ?? [])
.filter((b) => requestedPackages.includes(b.packageRevisionId))
.map((b) => ({
atom: c.atomId,
interface: c.interfaceRevisionId,
member: b.memberId,
packageRevision: b.packageRevisionId,
id: b.moduleId,
})),
),
presentations: (plan.definition.presentations ?? []) presentations: (plan.definition.presentations ?? [])
.filter((p) => requestedPackages.includes(p.packageRevisionId)) .filter((p) => requestedPackages.includes(p.packageRevisionId))
.map((p) => ({ id: p.id, subject: p.subject, packageRevision: p.packageRevisionId, component: p.componentId })), .map((p) => ({ id: p.id, subject: p.subject, packageRevision: p.packageRevisionId, component: p.componentId })),
@@ -99,6 +117,12 @@ export interface ExecutionInstallation {
} }
/** Safe to expose in a presentation catalog: no source paths, state slots, or host configuration. */ /** Safe to expose in a presentation catalog: no source paths, state slots, or host configuration. */
export interface InstalledComponent { export interface InstalledComponent {
readonly role: {
readonly interface: string;
readonly member: string;
readonly subject: string;
readonly contract: string;
};
readonly packageRevision: string; readonly packageRevision: string;
readonly id: string; readonly id: string;
readonly subject: string; readonly subject: string;
@@ -176,6 +200,17 @@ function assembleExecutionInstallation(
routes[url] ??= { path: path.join(root, file.path), mediaType: file.mediaType }; routes[url] ??= { path: path.join(root, file.path), mediaType: file.mediaType };
} }
components.push({ components.push({
role: (() => {
const module = plan.definition.packages
.find((p) => p.revision === revision)!
.modules!.find((m) => m.id === component.id)!;
return {
interface: module.interfaceRevisionId,
member: module.memberId,
subject: component.subject,
contract: module.contract,
};
})(),
packageRevision: revision, packageRevision: revision,
id: component.id, id: component.id,
subject: component.subject, subject: component.subject,