diff --git a/nix/checked-candidate.nix b/nix/checked-candidate.nix index b21a155..7a9c17f 100644 --- a/nix/checked-candidate.nix +++ b/nix/checked-candidate.nix @@ -119,6 +119,12 @@ let typescript = runtimePackages.camino-replica-core.dependencies; packageRevisionId = candidate.revision.revisionId; } + // pkgs.lib.optionalAttrs ((candidate.revision.components or [ ]) != [ ]) { + react = { + sdk = runtimePackages.camino-react; + runtime = runtimePackages.camino-react-runtime; + }; + } else { schema = diff --git a/src/capability-language/execution-scaffold.ts b/src/capability-language/execution-scaffold.ts index befeb3b..64d40ba 100644 --- a/src/capability-language/execution-scaffold.ts +++ b/src/capability-language/execution-scaffold.ts @@ -56,7 +56,7 @@ export async function executionScaffold( pkgs = import nixpkgs { inherit system; }; in { quixosPackages.checkedArtifacts = { plan, generator, core, sharedRuntime, typescript, packageRevisionId }: - (import (helpers + "/portable-artifacts.nix")) { + (import (helpers + "/artifacts.nix")) { inherit pkgs plan core sharedRuntime typescript packageRevisionId; protocol = generator; src = ./.; diff --git a/src/capability-language/parser.ts b/src/capability-language/parser.ts index 240f044..8cf297b 100644 --- a/src/capability-language/parser.ts +++ b/src/capability-language/parser.ts @@ -2692,6 +2692,15 @@ const compileCapabilityResourceSourceInternal = ( externalInterfaces, revision: lowerPackage(state, context, options.source), }; + if (resource.revision.components?.length) { + resource.revision.clientInterfaces = Object.fromEntries( + imports.flatMap((entry) => { + if (entry.kind !== "interface") return []; + const contract = options.environment?.interfaces?.get(entry.binding); + return contract && !contract.template ? [[entry.binding, contract.revisionId]] : []; + }), + ); + } } if (resource.kind === "interface") { diff --git a/src/capability-model/presentations.ts b/src/capability-model/presentations.ts index 8d8b147..154133e 100644 --- a/src/capability-model/presentations.ts +++ b/src/capability-model/presentations.ts @@ -15,6 +15,10 @@ export function validatePresentations(workspace: WorkspaceRevision): CapabilityV if (!contract || contract.template) fail(path, `Expected an imported closed subject interface: ${id}`); }; for (const [index, pkg] of workspace.packageImports.entries()) { + for (const [alias, id] of Object.entries(pkg.clientInterfaces ?? {})) { + text(alias, `packageImports[${index}].clientInterfaces`); + subject(id, `packageImports[${index}].clientInterfaces.${alias}`); + } const ids = new Set(pkg.exports.map((entry) => entry.id as string)); const names = new Set([ ...pkg.exports.map((entry) => entry.displayName), diff --git a/src/capability-model/types.ts b/src/capability-model/types.ts index 01e0e89..14b53d0 100644 --- a/src/capability-model/types.ts +++ b/src/capability-model/types.ts @@ -305,6 +305,8 @@ export interface WorkspacePresentation { } export interface PackageRevision { + /** Closed interface imports exposed to this package's checked component bindings. */ + clientInterfaces?: Record; controllers?: PackageController[]; components?: PackageComponent[]; queries?: import("../query/types.js").QueryDeclaration[]; diff --git a/src/execution-world/client-contracts.ts b/src/execution-world/client-contracts.ts index 78ce3ce..5bd482a 100644 --- a/src/execution-world/client-contracts.ts +++ b/src/execution-world/client-contracts.ts @@ -127,7 +127,62 @@ export function generatePackageQueryClientBindings( ): string { const queries = (pkg.checkedQueries ?? []).map( (query) => - `${JSON.stringify(query.declaration.displayName)}: definePackageQuery<${tsType(query.variables)}, ${tsType(query.output)}>(${JSON.stringify(publicQueryContract(pkg, query.declaration.id))})`, + `${JSON.stringify(query.declaration.displayName)}: definePackageQuery<${tsType(query.variables)}, ${tsType(query.output)}, ${JSON.stringify(publicQueryContract(pkg, query.declaration.id).placement)}>(${JSON.stringify(publicQueryContract(pkg, query.declaration.id))})`, ); return `// Generated from checked package query contracts. Regenerate; do not edit.\nimport { definePackageQuery, type ManagedReference } from ${JSON.stringify(runtimeModule)};\nexport const queries = {${queries.join(",\n")}} as const;\n`; } + +/** Receipt-covered bindings expose the package's imports, component subjects and + * declared queries, never the workspace's complete atom/interface inventory. */ +export function generateComponentClientBindings(plan: ExecutionPlan, packageRevision: string): string { + const pkg = plan.definition.workspace.packageImports.find((pkg) => pkg.revisionId === packageRevision); + if (!pkg || !pkg.components?.length) + throw new ExecutionPlanError("NO_COMPONENT_CONTRACT", packageRevision, "Package declares no component entries"); + const byId = new Map(plan.definition.workspace.interfaceImports.map((iface) => [iface.revisionId as string, iface])); + const find = (id: string) => { + const contract = byId.get(id); + if (!contract || contract.template) + throw new ExecutionPlanError("OPEN_CLIENT_CONTRACT", id, "Component contracts must be closed"); + return contract; + }; + const imports = Object.fromEntries( + Object.entries(pkg.clientInterfaces ?? {}).map(([alias, id]) => [alias, find(id)]), + ); + const subjects = Object.fromEntries( + pkg.components.map((component) => [component.displayName, find(component.subject)]), + ); + const interfaces = generateInterfaceClientBindings(imports, "@quixos/camino-react"); + const subjectBindings = generateInterfaceClientBindings(subjects, "@quixos/camino-react") + .split("\n") + .filter((line) => !line.startsWith("import ")) + .join("\n") + .replace("export const interfaces =", "export const subjects ="); + const queries = generatePackageQueryClientBindings(pkg, "@quixos/camino-react") + .split("\n") + .filter((line) => !line.startsWith("import ")) + .join("\n"); + return ( + interfaces + + '\nimport {definePackageQuery,type ComponentType,type ComponentProps} from "@quixos/camino-react";\n' + + subjectBindings + + "\n" + + queries + + "\nexport interface Components {\n" + + pkg.components + .map( + (component) => + `${JSON.stringify(component.displayName)}: ComponentType>;`, + ) + .join("\n") + + "\n}\n" + ); +} +export function componentAssetDeclarations(): string { + return ( + "// Generated component asset modules. Regenerate; do not edit.\n" + + ["svg", "png", "jpg", "jpeg", "gif", "webp", "avif", "woff", "woff2", "ttf", "ico"] + .map((extension) => `declare module "*.${extension}" { const value: string; export default value; }`) + .join("\n") + + '\ndeclare module "*.css";\n' + ); +} diff --git a/src/execution-world/installation.ts b/src/execution-world/installation.ts index 6c43ef7..a18832d 100644 --- a/src/execution-world/installation.ts +++ b/src/execution-world/installation.ts @@ -104,6 +104,7 @@ export interface InstalledComponent { readonly subject: string; readonly url: string; readonly exportName: string; + readonly styles: readonly string[]; readonly compatibility: ComponentArtifact["compatibility"]; readonly sharedModules: Readonly>; } @@ -163,6 +164,7 @@ export async function loadExecutionInstallation( subject: component.subject, compatibility: component.compatibility, exportName: component.exportName, + styles: component.files.filter((file) => file.mediaType === "text/css").map(contentArtifactUrl), sharedModules, url: contentArtifactUrl(component.files.find((file) => file.path === component.path)!), }); diff --git a/src/execution-world/receipts.ts b/src/execution-world/receipts.ts index 57d2665..fcd681a 100644 --- a/src/execution-world/receipts.ts +++ b/src/execution-world/receipts.ts @@ -3,6 +3,7 @@ import { generatePortableTypeScriptBindings, generateRemoteTypeScriptBindings } import { type ExecutionPlan, ExecutionPlanError, hexDigest } from "./plan.js"; import { type ArtifactReader, type PackageArtifacts } from "./artifacts.js"; import { loadArtifactWorld, type LoadedArtifactWorld } from "./files.js"; +import { generateComponentClientBindings, componentAssetDeclarations } from "./client-contracts.js"; export interface ArtifactReceipt { schemaVersion: 1; @@ -57,6 +58,16 @@ export function expectedArtifactReceipt( sources.set(path, bytes); return { target, path, digest: hashBytes(bytes), bytes: bytes.length }; }); + if (manifest.components?.length) { + for (const [path, source] of [ + ["share/quixos/generated/client.ts", generateComponentClientBindings(plan, pkg.revision)], + ["share/quixos/generated/component-assets.d.ts", componentAssetDeclarations()], + ] as const) { + const bytes = new TextEncoder().encode(source); + sources.set(path, bytes); + generated.push({ target: "window", path, digest: hashBytes(bytes), bytes: bytes.length }); + } + } // Custom language services need no TypeScript adapter. A checked JS module // exporting remote handlers does, and must carry the exact generated source. if ( diff --git a/test/emit-managed.ts b/test/emit-managed.ts index 4036cef..7861b67 100644 --- a/test/emit-managed.ts +++ b/test/emit-managed.ts @@ -45,6 +45,11 @@ assert.ok(data.ok && data.resource.kind === "interface", JSON.stringify(data.dia assert.ok(constructors.ok && constructors.resource.kind === "interface", JSON.stringify(constructors.diagnostics)); const dataInterface = data.resource.revision, factoriesInterface = constructors.resource.revision; +const ui = compileCapabilityResourceSource( + 'import interface Data; package UI id "package:ui" revision "package:ui@1" { component Card id "component:card" subject Data runtime react; }', + { source, environment: { interfaces: new Map([["Data", dataInterface]]), interfaceClosure: [dataInterface] } }, +); +assert.ok(ui.ok && ui.resource.kind === "package", JSON.stringify(ui.diagnostics)); for (const variant of ["base", "added", "gateway", "gateway-next", "session"]) { const session = variant === "session", gateway = session || variant.startsWith("gateway"), @@ -57,7 +62,8 @@ package Owner id "package:owner" revision "package:owner@1" { ${gateway ? 'function create id "create" : unit -> atom-ref execution transaction targets [server] requires { creation entry id "entry:birth" : Entry input unit; };' : ""} ${ session - ? `function createOne id "create-one" : string -> atom-ref execution transaction targets [browser, server] requires { creation entry id "one:birth" : Entry input unit; }; + ? `component Editor id "component:owner-editor" subject Data runtime react; + function createOne id "create-one" : string -> atom-ref execution transaction targets [browser, server] requires { creation entry id "one:birth" : Entry input unit; }; function createTwo id "create-two" : unit -> atom-ref execution transaction targets [browser, server] requires { creation entry id "two:birth" : Independent input unit; }; operation revise id "revise" : record {body:change; notes:change; reject:bool;} -> unit mode call execution transaction targets [browser, server] receiver any requires { state body id "revise:body" : string [edit]; state notes id "revise:notes" : string [edit]; @@ -98,6 +104,7 @@ package Owner id "package:owner" revision "package:owner@1" { const result = compileCapabilitySource( `workspace Managed id "workspace:managed-process" revision "workspace:managed-process@${variant}" commit "${commit}" { import package Owner; + ${session ? "import package UI;" : ""} ${ session ? `import interface Data; import interface Factories; @@ -118,8 +125,11 @@ package Owner id "package:owner" revision "package:owner@1" { }`, "workspace.qx", { - packages: new Map([["Owner", pkg.resource.revision]]), - packageClosure: [pkg.resource.revision], + packages: new Map([ + ["Owner", pkg.resource.revision], + ...(session ? [["UI", ui.resource.revision] as const] : []), + ]), + packageClosure: [pkg.resource.revision, ...(session ? [ui.resource.revision] : [])], ...(gateway ? { interfaces: new Map([