Build checked component artifacts and shared React session SDK (WI-09)
This commit is contained in:
@@ -119,6 +119,12 @@ let
|
|||||||
typescript = runtimePackages.camino-replica-core.dependencies;
|
typescript = runtimePackages.camino-replica-core.dependencies;
|
||||||
packageRevisionId = candidate.revision.revisionId;
|
packageRevisionId = candidate.revision.revisionId;
|
||||||
}
|
}
|
||||||
|
// pkgs.lib.optionalAttrs ((candidate.revision.components or [ ]) != [ ]) {
|
||||||
|
react = {
|
||||||
|
sdk = runtimePackages.camino-react;
|
||||||
|
runtime = runtimePackages.camino-react-runtime;
|
||||||
|
};
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
schema =
|
schema =
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export async function executionScaffold(
|
|||||||
pkgs = import nixpkgs { inherit system; };
|
pkgs = import nixpkgs { inherit system; };
|
||||||
in {
|
in {
|
||||||
quixosPackages.checkedArtifacts = { plan, generator, core, sharedRuntime, typescript, packageRevisionId }:
|
quixosPackages.checkedArtifacts = { plan, generator, core, sharedRuntime, typescript, packageRevisionId }:
|
||||||
(import (helpers + "/portable-artifacts.nix")) {
|
(import (helpers + "/artifacts.nix")) {
|
||||||
inherit pkgs plan core sharedRuntime typescript packageRevisionId;
|
inherit pkgs plan core sharedRuntime typescript packageRevisionId;
|
||||||
protocol = generator;
|
protocol = generator;
|
||||||
src = ./.;
|
src = ./.;
|
||||||
|
|||||||
@@ -2692,6 +2692,15 @@ const compileCapabilityResourceSourceInternal = (
|
|||||||
externalInterfaces,
|
externalInterfaces,
|
||||||
revision: lowerPackage(state, context, options.source),
|
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") {
|
if (resource.kind === "interface") {
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export function validatePresentations(workspace: WorkspaceRevision): CapabilityV
|
|||||||
if (!contract || contract.template) fail(path, `Expected an imported closed subject interface: ${id}`);
|
if (!contract || contract.template) fail(path, `Expected an imported closed subject interface: ${id}`);
|
||||||
};
|
};
|
||||||
for (const [index, pkg] of workspace.packageImports.entries()) {
|
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 ids = new Set(pkg.exports.map((entry) => entry.id as string));
|
||||||
const names = new Set([
|
const names = new Set([
|
||||||
...pkg.exports.map((entry) => entry.displayName),
|
...pkg.exports.map((entry) => entry.displayName),
|
||||||
|
|||||||
@@ -305,6 +305,8 @@ export interface WorkspacePresentation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PackageRevision {
|
export interface PackageRevision {
|
||||||
|
/** Closed interface imports exposed to this package's checked component bindings. */
|
||||||
|
clientInterfaces?: Record<string, InterfaceRevisionId>;
|
||||||
controllers?: PackageController[];
|
controllers?: PackageController[];
|
||||||
components?: PackageComponent[];
|
components?: PackageComponent[];
|
||||||
queries?: import("../query/types.js").QueryDeclaration[];
|
queries?: import("../query/types.js").QueryDeclaration[];
|
||||||
|
|||||||
@@ -127,7 +127,62 @@ export function generatePackageQueryClientBindings(
|
|||||||
): string {
|
): string {
|
||||||
const queries = (pkg.checkedQueries ?? []).map(
|
const queries = (pkg.checkedQueries ?? []).map(
|
||||||
(query) =>
|
(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`;
|
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<ComponentProps<${JSON.stringify(component.subject)}>>;`,
|
||||||
|
)
|
||||||
|
.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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export interface InstalledComponent {
|
|||||||
readonly subject: string;
|
readonly subject: string;
|
||||||
readonly url: string;
|
readonly url: string;
|
||||||
readonly exportName: string;
|
readonly exportName: string;
|
||||||
|
readonly styles: readonly string[];
|
||||||
readonly compatibility: ComponentArtifact["compatibility"];
|
readonly compatibility: ComponentArtifact["compatibility"];
|
||||||
readonly sharedModules: Readonly<Record<keyof ComponentArtifact["sharedModules"], string>>;
|
readonly sharedModules: Readonly<Record<keyof ComponentArtifact["sharedModules"], string>>;
|
||||||
}
|
}
|
||||||
@@ -163,6 +164,7 @@ export async function loadExecutionInstallation(
|
|||||||
subject: component.subject,
|
subject: component.subject,
|
||||||
compatibility: component.compatibility,
|
compatibility: component.compatibility,
|
||||||
exportName: component.exportName,
|
exportName: component.exportName,
|
||||||
|
styles: component.files.filter((file) => file.mediaType === "text/css").map(contentArtifactUrl),
|
||||||
sharedModules,
|
sharedModules,
|
||||||
url: contentArtifactUrl(component.files.find((file) => file.path === component.path)!),
|
url: contentArtifactUrl(component.files.find((file) => file.path === component.path)!),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { generatePortableTypeScriptBindings, generateRemoteTypeScriptBindings }
|
|||||||
import { type ExecutionPlan, ExecutionPlanError, hexDigest } from "./plan.js";
|
import { type ExecutionPlan, ExecutionPlanError, hexDigest } from "./plan.js";
|
||||||
import { type ArtifactReader, type PackageArtifacts } from "./artifacts.js";
|
import { type ArtifactReader, type PackageArtifacts } from "./artifacts.js";
|
||||||
import { loadArtifactWorld, type LoadedArtifactWorld } from "./files.js";
|
import { loadArtifactWorld, type LoadedArtifactWorld } from "./files.js";
|
||||||
|
import { generateComponentClientBindings, componentAssetDeclarations } from "./client-contracts.js";
|
||||||
|
|
||||||
export interface ArtifactReceipt {
|
export interface ArtifactReceipt {
|
||||||
schemaVersion: 1;
|
schemaVersion: 1;
|
||||||
@@ -57,6 +58,16 @@ export function expectedArtifactReceipt(
|
|||||||
sources.set(path, bytes);
|
sources.set(path, bytes);
|
||||||
return { target, path, digest: hashBytes(bytes), bytes: bytes.length };
|
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
|
// Custom language services need no TypeScript adapter. A checked JS module
|
||||||
// exporting remote handlers does, and must carry the exact generated source.
|
// exporting remote handlers does, and must carry the exact generated source.
|
||||||
if (
|
if (
|
||||||
|
|||||||
+13
-3
@@ -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));
|
assert.ok(constructors.ok && constructors.resource.kind === "interface", JSON.stringify(constructors.diagnostics));
|
||||||
const dataInterface = data.resource.revision,
|
const dataInterface = data.resource.revision,
|
||||||
factoriesInterface = constructors.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"]) {
|
for (const variant of ["base", "added", "gateway", "gateway-next", "session"]) {
|
||||||
const session = variant === "session",
|
const session = variant === "session",
|
||||||
gateway = session || variant.startsWith("gateway"),
|
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<Entry> execution transaction targets [server] requires { creation entry id "entry:birth" : Entry input unit; };' : ""}
|
${gateway ? 'function create id "create" : unit -> atom-ref<Entry> execution transaction targets [server] requires { creation entry id "entry:birth" : Entry input unit; };' : ""}
|
||||||
${
|
${
|
||||||
session
|
session
|
||||||
? `function createOne id "create-one" : string -> atom-ref<Entry> 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<Entry> execution transaction targets [browser, server] requires { creation entry id "one:birth" : Entry input unit; };
|
||||||
function createTwo id "create-two" : unit -> atom-ref<Independent> execution transaction targets [browser, server] requires { creation entry id "two:birth" : Independent input unit; };
|
function createTwo id "create-two" : unit -> atom-ref<Independent> execution transaction targets [browser, server] requires { creation entry id "two:birth" : Independent input unit; };
|
||||||
operation revise id "revise" : record {body:change<string>; notes:change<string>; reject:bool;} -> unit mode call execution transaction targets [browser, server] receiver any requires {
|
operation revise id "revise" : record {body:change<string>; notes:change<string>; 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];
|
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(
|
const result = compileCapabilitySource(
|
||||||
`workspace Managed id "workspace:managed-process" revision "workspace:managed-process@${variant}" commit "${commit}" {
|
`workspace Managed id "workspace:managed-process" revision "workspace:managed-process@${variant}" commit "${commit}" {
|
||||||
import package Owner;
|
import package Owner;
|
||||||
|
${session ? "import package UI;" : ""}
|
||||||
${
|
${
|
||||||
session
|
session
|
||||||
? `import interface Data; import interface Factories;
|
? `import interface Data; import interface Factories;
|
||||||
@@ -118,8 +125,11 @@ package Owner id "package:owner" revision "package:owner@1" {
|
|||||||
}`,
|
}`,
|
||||||
"workspace.qx",
|
"workspace.qx",
|
||||||
{
|
{
|
||||||
packages: new Map([["Owner", pkg.resource.revision]]),
|
packages: new Map([
|
||||||
packageClosure: [pkg.resource.revision],
|
["Owner", pkg.resource.revision],
|
||||||
|
...(session ? [["UI", ui.resource.revision] as const] : []),
|
||||||
|
]),
|
||||||
|
packageClosure: [pkg.resource.revision, ...(session ? [ui.resource.revision] : [])],
|
||||||
...(gateway
|
...(gateway
|
||||||
? {
|
? {
|
||||||
interfaces: new Map([
|
interfaces: new Map([
|
||||||
|
|||||||
Reference in New Issue
Block a user