2026-09-22 14:42:54 -07:00
import { installDependencies } from "./install-dependencies.mjs" ;
2026-10-01 01:37:46 -07:00
import { checkComponentTypes } from "./component-types.mjs" ;
import { measureBuild } from "./build-profile.mjs" ;
2026-09-21 15:24:05 -07:00
import fs from "node:fs/promises" ;
import path from "node:path" ;
import { createHash } from "node:crypto" ;
import { execFileSync } from "node:child_process" ;
2026-09-29 23:51:08 -07:00
import { pathToFileURL } from "node:url" ;
2026-09-21 15:24:05 -07:00
const digest = ( bytes ) => createHash ( "sha256" ). update ( bytes ). digest ( "hex" );
2026-09-30 22:03:17 -07:00
export async function buildComponents ({ config , compiler , contract , pkg , output , recordFile , generated }) {
2026-09-21 15:24:05 -07:00
const settings = config . components ;
if ( ! settings ) {
if ( pkg . components ? . length ) throw Error ( "Declared components require checked component build settings" );
return [];
}
2026-09-22 13:51:32 -07:00
const declarations = config . group
? ( pkg . components ?? []). filter (( entry ) => Object . hasOwn ( settings . entries , entry . id ))
: ( pkg . components ?? []);
2026-09-21 15:24:05 -07:00
const entries = Object . entries ( settings . entries );
if (
entries . length !== declarations . length ||
entries . some (([ id ]) => ! declarations . some (( component ) => component . id === id ))
)
throw Error ( "Component build entries differ from package declarations" );
const runtime = JSON . parse ( await fs . readFile ( path . join ( settings . runtime , "runtime.json" ), "utf8" ));
if (
runtime . sdkCodeDigest !== digest ( await fs . readFile ( path . join ( settings . sdk , "dist/index.js" ))) ||
runtime . sdkTypeDigest !== digest ( await fs . readFile ( path . join ( settings . sdk , "dist/index.d.ts" )))
)
throw Error ( "Component SDK differs from selected shared runtime" );
const sharedFiles = [];
for ( const file of runtime . files ) {
compiler . artifactPath ( file . path );
const bytes = await fs . readFile ( path . join ( settings . runtime , file . path ));
if ( bytes . length !== file . bytes || digest ( bytes ) !== file . digest ) throw Error ( "Modified component runtime" );
const target = "share/quixos/component-shared/" + file . path ;
await fs . mkdir ( path . dirname ( path . join ( output , target )), { recursive : true });
await fs . writeFile ( path . join ( output , target ), bytes );
sharedFiles . push ( await recordFile ( target , file . mediaType ));
}
const sharedModules = Object . fromEntries (
compiler . componentSharedModules . map (( name ) => {
const file = sharedFiles . find (( file ) => file . path === "share/quixos/component-shared/" + runtime . modules [ name ]);
if ( ! file ) throw Error ( "Missing shared component module " + name );
return [ name , file . path ];
}),
);
const imports = Object . fromEntries (
Object . entries ( sharedModules ). map (([ name , file ]) => [
name ,
compiler . contentArtifactUrl ( sharedFiles . find (( value ) => value . path === file )),
]),
);
const core = runtime . files . find (( file ) => file . path === "core.mjs" );
const selected = JSON . parse ( await fs . readFile ( path . join ( config . sharedRuntime , "runtime.json" ), "utf8" )). files . find (
( file ) => file . path === "core.mjs" ,
);
if ( core ? . digest !== selected ? . digest ) throw Error ( "Component runtime core differs from portable runtime core" );
const work = path . resolve ( "work-components" );
await fs . cp ( config . source , work , { recursive : true , dereference : false });
const writable = async ( directory ) => {
await fs . chmod ( directory , 0o755 );
for ( const item of await fs . readdir ( directory , { withFileTypes : true })) {
const file = path . join ( directory , item . name );
if ( item . isSymbolicLink ()) throw Error ( "Authored component source cannot contain symlinks" );
if ( item . isDirectory ()) await writable ( file );
else await fs . chmod ( file , 0o644 );
}
};
await writable ( work );
const modules = path . join ( work , "node_modules" );
await fs . mkdir ( path . join ( modules , "@quixos" ), { recursive : true });
2026-09-22 14:42:54 -07:00
await installDependencies ({
destination : modules ,
application : config . nodeModules ,
defaults : [ path . join ( settings . dependencies , "node_modules" )],
selected : { "@quixos/camino-react" : settings . sdk },
reserved : [
"react" ,
"react-dom" ,
"scheduler" ,
"csstype" ,
"@types/react" ,
"@types/react-dom" ,
"@types/prop-types" ,
"@quixos/camino-react" ,
"@quixos/camino-replica-core" ,
"@quixos/camino-replica-client" ,
"@quixos/camino-replica-engine" ,
],
});
const packageFile = path . join ( work , "package.json" );
const packageMetadata = await fs . readFile ( packageFile , "utf8" ). then ( JSON . parse , ( error ) => {
if ( error . code === "ENOENT" ) return {};
throw error ;
});
await fs . writeFile ( packageFile , JSON . stringify ({ ... packageMetadata , type : "module" }));
// CommonJS dependencies may require React. An ESM bridge lets esbuild lower
// that require while retaining the one host-owned runtime instance.
const sharedAliases = [];
for ( const [ name , url ] of Object . entries ( imports )) {
const shim = path . join ( work , "shared-" + digest ( Buffer . from ( name )). slice ( 0 , 16 ) + ".mjs" );
await fs . writeFile (
shim ,
`export * from ${ JSON . stringify ( url ) } ;` +
2026-09-24 11:52:17 -07:00
([ "@quixos/camino-react" , "@quixos/camino-react/ui" ]. includes ( name )
? ""
: `export {default} from ${ JSON . stringify ( url ) } ;` ),
2026-09-22 14:42:54 -07:00
);
sharedAliases . push ( "--alias:" + name + "=" + shim , "--external:" + url );
2026-09-21 15:24:05 -07:00
}
const bindingPath = compiler . artifactPath ( settings . bindingOutput ),
2026-09-30 22:03:17 -07:00
bindings = contract . definition . component ,
2026-09-21 15:24:05 -07:00
assets = compiler . componentAssetDeclarations ();
await fs . mkdir ( path . dirname ( path . join ( work , bindingPath )), { recursive : true });
await fs . writeFile ( path . join ( work , bindingPath ), bindings );
const assetPath = "component-assets.d.ts" ;
await fs . writeFile ( path . join ( work , assetPath ), assets );
await fs . writeFile ( path . join ( output , "share/quixos/generated/client.ts" ), bindings );
await fs . writeFile ( path . join ( output , "share/quixos/generated/component-assets.d.ts" ), assets );
generated . set ( "component-client" , { file : path . join ( work , bindingPath ), source : bindings });
generated . set ( "component-assets" , { file : path . join ( work , assetPath ), source : assets });
2026-09-22 13:51:32 -07:00
// Public props are type-only inputs owned by imported interface resources.
// Augment the generated nominal registry without importing an implementation.
const propsImports = [],
propsMembers = [],
seenProps = new Set ();
for ( const root of settings . contracts ?? []) {
const companion = JSON . parse ( await fs . readFile ( path . join ( root , "react-contracts.json" ), "utf8" ));
if ( companion . schemaVersion !== 1 ) throw Error ( "Unsupported React props companion" );
2026-09-30 22:03:17 -07:00
const contracts = contract . definition . moduleInterfaces . filter (
2026-09-22 13:51:32 -07:00
( iface ) =>
2026-09-30 22:03:17 -07:00
iface . revisionId === companion . interfaceRevisionId || iface . definitionId === companion . interfaceRevisionId ,
2026-09-22 13:51:32 -07:00
);
if ( ! contracts . length ) continue ;
for ( const [ memberId , entry ] of Object . entries ( companion . members )) {
if ( ! /^[$A-Z_a-z][$\w]*$/ . test ( entry . export )) throw Error ( "Invalid props type export" );
const alias = `Props ${ propsImports . length } ` ;
propsImports . push (
`import type { ${ entry . export } as ${ alias } } from ${ JSON . stringify ( path . join ( root , "types" , compiler . artifactPath ( entry . path ))) } ;` ,
);
for ( const iface of contracts ) {
2026-09-30 22:03:17 -07:00
if ( ! iface . members . includes ( memberId )) throw Error ( "Props companion names an unknown module member" );
2026-09-22 13:51:32 -07:00
const key = JSON . stringify ([ iface . revisionId , memberId ]);
if ( seenProps . has ( key )) throw Error ( "Duplicate React props companion for " + key );
seenProps . add ( key );
propsMembers . push ( ` ${ JSON . stringify ( key ) } : ${ alias } ;` );
}
}
}
const propsPath = "component-props.d.ts" ;
await fs . writeFile (
path . join ( work , propsPath ),
propsImports . join ( "\n" ) +
`\nexport {};\ndeclare module ${ JSON . stringify ( "./" + bindingPath . replace ( /\.ts$/ , ".js" )) } { interface ModuleProps { ${ propsMembers . join ( "\n" ) } } }\n` ,
);
2026-10-01 01:37:46 -07:00
const witnesses = [];
2026-09-21 15:24:05 -07:00
for ( const [ id , entry ] of entries ) {
const declaration = declarations . find (( component ) => component . id === id ),
source = compiler . artifactPath ( entry . entry );
if ( ! /^[$A-Z_a-z][$\w]*$/ . test ( entry . export )) throw Error ( "Invalid component export name" );
2026-10-01 01:37:46 -07:00
const key = digest ( Buffer . from ( id )). slice ( 0 , 24 );
2026-09-22 13:51:32 -07:00
const witness = path . join ( work , "component-check-" + key + ".tsx" );
const module = pkg . modules . find (( module ) => module . id === id );
2026-09-21 15:24:05 -07:00
await fs . writeFile (
witness ,
2026-09-22 13:51:32 -07:00
`import type {Modules} from ${ JSON . stringify ( "./" + bindingPath . replace ( /\.ts$/ , ".js" )) } ;import { ${ entry . export } as Component} from ${ JSON . stringify ( "./" + source . replace ( /\.[cm]?tsx?$/ , ".js" )) } ;const checked:Modules[ ${ JSON . stringify ( declaration . displayName ) } ]=Component;export default checked;\n` +
( module . contract === "org.quixos.react.subject-only/1"
2026-09-27 16:34:53 -07:00
? `import type {ComponentProps} from "react";declare const props:Pick<ComponentProps<Modules[ ${ JSON . stringify ( declaration . displayName ) } ]>,"subject"|"input">;const mount = <Component {...props}/>;\n`
2026-09-22 13:51:32 -07:00
: "" ),
2026-09-21 15:24:05 -07:00
);
2026-10-01 01:37:46 -07:00
witnesses . push ( path . basename ( witness ));
}
checkComponentTypes ({
node : config . node ,
tsc : config . tsc ,
work ,
witnesses ,
assets : assetPath ,
props : propsPath ,
packageRevision : pkg . revision ,
});
const components = [];
const { checkControls } = await import ( pathToFileURL ( path . join ( settings . sdk , "lint-controls.mjs" )));
const { default : ts } = await import ( pathToFileURL ( path . resolve ( path . dirname ( config . tsc ), "../lib/typescript.js" )));
const checkedControls = new Set ();
for ( const [ id , entry ] of entries ) {
const declaration = declarations . find (( component ) => component . id === id ),
source = compiler . artifactPath ( entry . entry ),
key = digest ( Buffer . from ( id )). slice ( 0 , 24 ),
directory = "share/quixos/components/" + key ;
2026-09-21 15:24:05 -07:00
const metadata = path . join ( work , "component-meta-" + key + ".json" );
2026-09-22 13:51:32 -07:00
const facade = "component-entry-" + key + ".ts" ;
await fs . writeFile (
path . join ( work , facade ),
`export { ${ entry . export } } from ${ JSON . stringify ( "./" + source . replace ( /\.[cm]?tsx?$/ , ".js" )) } ;\n` ,
);
2026-09-21 15:24:05 -07:00
await fs . mkdir ( path . join ( output , directory ), { recursive : true });
2026-10-01 01:37:46 -07:00
measureBuild ( "component-bundle" , { package : pkg . revision , component : id }, () =>
execFileSync (
config . esbuild ,
[
facade ,
"--bundle" ,
"--platform=browser" ,
"--format=esm" ,
"--target=es2024" ,
"--jsx=automatic" ,
'--define:process.env.NODE_ENV="production"' ,
"--metafile=" + metadata ,
"--outfile=" + path . join ( output , directory , "module.mjs" ),
... sharedAliases ,
...[ "svg" , "png" , "jpg" , "jpeg" , "gif" , "webp" , "avif" , "woff" , "woff2" , "ttf" , "ico" ]. map (
( extension ) => "--loader:." + extension + "=dataurl" ,
),
],
{ cwd : work , stdio : "inherit" },
),
2026-09-21 15:24:05 -07:00
);
const meta = JSON . parse ( await fs . readFile ( metadata , "utf8" )),
outputs = Object . entries ( meta . outputs );
2026-09-29 23:51:08 -07:00
for ( const file of Object . keys ( meta . inputs )) {
if ( ! /\.[cm]?[jt]sx?$/ . test ( file ) || file . includes ( "node_modules/" ) || checkedControls . has ( file )) continue ;
checkedControls . add ( file );
const errors = checkControls ( ts , await fs . readFile ( path . resolve ( work , file ), "utf8" ), file );
if ( errors . length ) throw Error ( errors . join ( "\n" ));
}
2026-09-21 15:24:05 -07:00
const main = outputs . find (([ file ]) => file . endsWith ( "/module.mjs" )) ? .[ 1 ];
if ( ! main ? . exports . includes ( entry . export )) throw Error ( "Component bundle lacks declared export" );
if (
Object . keys ( meta . inputs ). some (( file ) =>
/ camino - replica - engine | camino - replica - client | replicache | node_modules \ / ( ?: react | react - dom | scheduler )( ?: \ /| $ ) / . test (
file ,
),
)
)
throw Error ( "Component bundled an internal engine/client or duplicate React" );
if (
outputs . some (([, value ]) =>
value . imports . some (( imported ) => imported . external && ! Object . values ( imports ). includes ( imported . path )),
)
)
throw Error ( "Component has an undeclared external module or asset" );
const files = [... sharedFiles ];
for ( const [ file ] of outputs ) {
const absolute = path . resolve ( work , file ),
relative = path . relative ( output , absolute );
compiler . artifactPath ( relative );
if ( ! relative . startsWith ( directory + "/" )) throw Error ( "Component output escaped declared artifact closure" );
files . push ( await recordFile ( relative , relative . endsWith ( ".css" ) ? "text/css" : "text/javascript" ));
}
const body = {
id ,
subject : declaration . subject ,
2026-09-22 13:16:23 -07:00
compatibility : "org.quixos.react.esm/1" ,
2026-09-21 15:24:05 -07:00
path : directory + "/module.mjs" ,
exportName : entry . export ,
sharedModules ,
files ,
};
components . push ({ ... body , contentDigest : compiler . componentArtifactDigest ( body ) });
}
return components ;
}