diff --git a/.quixos-subtree-source.json b/.quixos-subtree-source.json index 1eb5a92..cb5a251 100644 --- a/.quixos-subtree-source.json +++ b/.quixos-subtree-source.json @@ -1,7 +1,7 @@ { "version": 1, "sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos", - "sourceCommit": "1de28b236de076d239752b77553f833dfbdb5b43", + "sourceCommit": "d74b619314718cce6da10800d278d6bcc04b71fa", "sourcePath": "quixos-instance/quixos-nix-helpers", "exportName": "quixos-nix-helpers", "mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git" diff --git a/.yarn/plugins/yarn-plugin-nixify.cjs b/.yarn/plugins/yarn-plugin-nixify.cjs new file mode 100644 index 0000000..44f7f79 --- /dev/null +++ b/.yarn/plugins/yarn-plugin-nixify.cjs @@ -0,0 +1 @@ +module.exports={name:"yarn-plugin-nixify",factory:function(e){var t;return(()=>{"use strict";var n={d:(e,t)=>{for(var r in t)n.o(t,r)&&!n.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t),r:e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})}},r={};n.r(r),n.d(r,{default:()=>E});const a=e("@yarnpkg/core"),o=e("clipanion");class i extends o.Command{constructor(...e){super(...e),this.locator=o.Option.String({required:!1})}async execute(){const e=await a.Configuration.find(this.context.cwd,this.context.plugins),{project:t}=await a.Project.find(e,this.context.cwd),n=await a.Cache.find(e),r=e.makeFetcher();return(await a.StreamReport.start({configuration:e,stdout:this.context.stdout},(async e=>{if(this.locator){const{locatorHash:o}=a.structUtils.parseLocator(this.locator,!0),i=t.originalPackages.get(o);if(!i)return void e.reportError(0,`Invalid locator: ${this.locator}`);await r.fetch(i,{checksums:t.storedChecksums,project:t,cache:n,fetcher:r,report:e})}else await e.startTimerPromise("Resolution step",(async()=>{await t.resolveEverything({report:e,lockfileOnly:!0})})),await e.startTimerPromise("Fetch step",(async()=>{await t.fetchEverything({cache:n,report:e,fetcher:r})}))}))).exitCode()}}i.paths=[["nixify","fetch"]];const s=e("@yarnpkg/fslib"),c=e("crypto");class l extends o.Command{constructor(...e){super(...e),this.locator=o.Option.String(),this.source=o.Option.String(),this.installLocation=o.Option.String()}async execute(){const e=await a.Configuration.find(this.context.cwd,this.context.plugins),{project:t}=await a.Project.find(e,this.context.cwd);return await t.restoreInstallState({restoreResolutions:!1}),(await a.StreamReport.start({configuration:e,stdout:this.context.stdout},(async n=>{await t.resolveEverything({report:n,lockfileOnly:!0});const r=a.structUtils.parseLocator(this.locator,!0),o=t.storedPackages.get(r.locatorHash);if(!o)return void n.reportError(0,`Invalid locator: ${this.locator}`);const i=s.ppath.join(t.cwd,this.installLocation);await s.xfs.mkdirpPromise(s.ppath.dirname(i)),await a.execUtils.execvp("cp",["-R",this.source,i],{cwd:t.cwd,strict:!0}),await a.execUtils.execvp("chmod",["-R","u+w",i],{cwd:t.cwd,strict:!0});const l=(0,c.createHash)("sha512");l.update(process.versions.node),e.triggerHook((e=>e.globalHashGeneration),t,(e=>{l.update("\0"),l.update(e)}));const d=l.digest("hex"),p=new Map,h=e=>{let n=p.get(e.locatorHash);if(void 0!==n)return n;const r=t.storedPackages.get(e.locatorHash);if(void 0===r)throw new Error("Assertion failed: The package should have been registered");const o=(0,c.createHash)("sha512");o.update(e.locatorHash),p.set(e.locatorHash,"");for(const e of r.dependencies.values()){const n=t.storedResolutions.get(e.descriptorHash);if(void 0===n)throw new Error(`Assertion failed: The resolution (${a.structUtils.prettyDescriptor(t.configuration,e)}) should have been registered`);const r=t.storedPackages.get(n);if(void 0===r)throw new Error("Assertion failed: The package should have been registered");o.update(h(r))}return n=o.digest("hex"),p.set(e.locatorHash,n),n},u=(0,c.createHash)("sha512").update(d).update(h(o)).update(i).digest("hex");t.storedBuildState.set(o.locatorHash,u),await t.persistInstallStateFile()}))).exitCode()}}l.paths=[["nixify","inject-build"]];const d=e("@yarnpkg/plugin-pnp"),p=JSON.stringify,h=(e,t,n=!1)=>t.split("\n").map((t=>t||n?e+t:t)).join("\n"),u=(e,t)=>{let n=e;for(const[e,r]of Object.entries(t))if("string"==typeof r&&(n=n.replace(new RegExp(`@@${e}@@`,"g"),r)),"boolean"==typeof r)for(;;){const t=n.split("\n"),a=t.indexOf(`#@@ IF ${e}`),o=t.indexOf(`#@@ ENDIF ${e}`);if(-1===a||o{if(!n)return;const o=s.npath.toPortablePath(this.binDir);for(const[r,a]of n.manifest.bin){const n=s.ppath.join(o,r),i=s.ppath.join(t.cwd,s.npath.toPortablePath(a));await this.writeWrapper(n,i,{configuration:e,project:t})}if(e.get("installNixBinariesForDependencies")){await t.resolveEverything({report:r,lockfileOnly:!0});const n=await a.scriptUtils.getPackageAccessibleBinaries(t.topLevelWorkspace.anchoredLocator,{project:t});for(const[r,[a,i]]of n.entries()){const n=s.ppath.join(o,r);await this.writeWrapper(n,s.npath.toPortablePath(i),{configuration:e,project:t})}}}))).exitCode()}async writeWrapper(e,t,{configuration:n,project:r}){let a;switch(n.get("nodeLinker")){case"pnp":{const e=(0,d.getPnpPath)(r),n=[];await s.xfs.existsPromise(e.cjs)&&n.push(`--require "${s.npath.fromPortablePath(e.cjs)}"`),await s.xfs.existsPromise(e.esmLoader)&&n.push(`--experimental-loader "${(0,f.pathToFileURL)(s.npath.fromPortablePath(e.esmLoader)).href}"`),a=u("#!/bin/sh\nexport NODE_OPTIONS='@@NODE_OPTIONS@@'\nexec '@@NODE_PATH@@' '@@BINARY_PATH@@' \"$@\"\n",{NODE_PATH:process.execPath,NODE_OPTIONS:n.join(" "),BINARY_PATH:t});break}case"node-modules":a=u("#!/bin/sh\nexec '@@NODE_PATH@@' '@@BINARY_PATH@@' \"$@\"\n",{NODE_PATH:process.execPath,BINARY_PATH:t});break;default:throw Error("Assertion failed: Invalid nodeLinker")}await s.xfs.writeFilePromise(e,a),await s.xfs.chmodPromise(e,493)}}g.paths=[["nixify","install-bin"]];const m=e("os"),y=e("@yarnpkg/plugin-patch"),b=(e,t)=>(0,c.createHash)(e).update(t).digest(),x=(e,t,{storePath:n="/nix/store",recursive:r=!1}={})=>{const[a,o]=t.split("-"),i=Buffer.from(o,"base64").toString("hex"),c=b("sha256",`fixed:out:${r?"r:":""}${a}:${i}:`).toString("hex"),l=(e=>{let t="",n=[...e].reverse().map((e=>e.toString(2).padStart(8,"0"))).join("");for(;n;)t+="0123456789abcdfghijklmnpqrsvwxyz"[parseInt(n.slice(0,5),2)],n=n.slice(5);return t})(((e,t)=>{const n=Buffer.alloc(20);for(let t=0;te.replace(/^\.+/,"").replace(/[^a-zA-Z0-9+._?=-]+/g,"-").slice(0,207)||"unknown",w=(e,t="sha512")=>t+"-"+Buffer.from(e,"hex").toString("base64"),k=e=>Buffer.from(e.split("-")[1],"base64").toString("hex"),I=2**32-1,P=(e,...t)=>{let n=0;const r=t.map((e=>{const t=Buffer.from(e);if(t.byteLength>I)throw Error(`NAR string too long: ${t.byteLength}`);return n+=8+8*Math.ceil(t.byteLength/8),t})),a=Buffer.alloc(n);let o=0;for(const e of r)a.writeUInt32LE(e.byteLength,o),e.copy(a,o+8),o+=8+8*Math.ceil(e.byteLength/8);e.write(a)},$=async(e,t,n)=>{if(t>I)throw Error(`NAR string too long: ${t}`);const r=Buffer.alloc(8);r.writeUInt32LE(t),e.write(r);for await(const t of n)e.write(t);const a=8-t%8;8!==a&&e.write(Buffer.alloc(a))},N=a.YarnVersion?.startsWith("3.")||!1,E={commands:[i,l,g],hooks:{afterAllInstalled:async(e,t)=>{!1!==t.persistProject&&e.configuration.get("enableNixify")&&await(async(e,t)=>{const{configuration:n,cwd:r}=e,{cache:o,report:i}=t,l=await s.xfs.realpathPromise(s.npath.toPortablePath((0,m.tmpdir)()));if(e.cwd.startsWith(l))return void i.reportInfo(0,`Skipping Nixify, because ${e.cwd} appears to be a temporary directory`);const d=n.get("nixExprPath"),f=n.get("yarnPath");let g;if(null===f){let e=(await s.xfs.readFilePromise(process.argv[1])).toString();if(e.startsWith("#!/nix/store/")){const t=e.substring(e.indexOf("\n")+1);e=`#!/usr/bin/env node\n${t}`}const t=a.hashUtils.makeHash(Buffer.from(e));g=["fetchurl {",` url = "https://repo.yarnpkg.com/${a.YarnVersion}/packages/yarnpkg-cli/bin/yarn.js";`,` hash = "${w(t)}";`,"}"].join("\n ")}else f.startsWith(r)?g="./"+s.ppath.relative(s.ppath.dirname(d),f):(g=p(f),i.reportWarning(0,`The Yarn path ${f} is outside the project - it may not be reachable by the Nix build`));const b=n.get("cacheFolder");let I;if(b.startsWith(r))I=p(s.ppath.relative(r,b));else{if(N||!n.get("enableGlobalCache"))throw Error(`The cache folder ${b} is outside the project, this is currently not supported`);I='".yarn/cache"'}const E=new Set;for(const e of n.sources.values())for(const t of e.split(", "))t.startsWith("<")||E.add(t);for(const e of E)s.ppath.resolve(r,e).startsWith(r)||i.reportWarning(0,`The config file ${e} is outside the project - it may not be reachable by the Nix build`);const D="./"+s.ppath.relative(s.ppath.dirname(d),s.ppath.resolve(r,"yarn.lock")),S=new Map,_=new Set(await s.xfs.readdirPromise(o.cwd)),L={unstablePackages:e.conditionalLocators};for(const t of e.storedPackages.values()){const{locatorHash:n}=t,r=e.storedChecksums.get(n),a=N?o.getLocatorPath(t,r||null,L):o.getLocatorPath(t,r||null);if(!a)continue;if(!_.has(s.ppath.basename(a)))continue;const i=r?o.getChecksumFilename(t,r):o.getVersionFilename(t);S.set(i,{pkg:t,checksum:r,cachePath:a})}const O=new Map,A=n.get("individualNixPackaging");let j="",T="";if(A){for(const[e,{pkg:t,checksum:n,cachePath:r}]of S.entries()){const o=a.structUtils.stringifyLocator(t),i=n?n.split("/").pop():await a.hashUtils.checksumFile(r);O.set(o,{cachePath:r,filename:e,hash:w(i)})}j="cacheEntries = {\n";for(const e of[...O.keys()].sort()){const t=O.get(e);j+=`${p(e)} = { ${[`filename = ${p(t.filename)};`,`hash = "${t.hash}";`].join(" ")} };\n`}j+="};"}else{const e=(0,c.createHash)("sha512");P(e,"nix-archive-1","(","type","directory");for(const t of[...S.keys()].sort()){const{cachePath:n}=S.get(t),{size:r}=await s.xfs.statPromise(n);P(e,"entry","(","name",t,"node","(","type","regular","contents"),await $(e,r,s.xfs.createReadStream(n)),P(e,")",")")}P(e,")"),e.end();for await(const t of e)T=w(t)}const C=n.get("isolatedNixBuilds");let R=new Set,F=[],B=[];const H=n.get("nodeLinker"),U=n.get("pnpUnpluggedFolder"),M=(t,n=new Set)=>{const r=a.structUtils.stringifyLocator(t);if(O.has(r)&&n.add(r),a.structUtils.isVirtualLocator(t)){const r=e.storedPackages.get(a.structUtils.devirtualizeLocator(t).locatorHash);if(!r)throw Error("Assertion failed: The locator should have been registered");M(r,n)}if(t.reference.startsWith("patch:")){const r=e.storedPackages.get(y.patchUtils.parseLocator(t).sourceLocator.locatorHash);if(!r)throw Error("Assertion failed: The locator should have been registered");M(r,n)}for(const r of t.dependencies.values()){const t=e.storedResolutions.get(r.descriptorHash);if(!t)throw Error("Assertion failed: The descriptor should have been registered");const a=e.storedPackages.get(t);if(!a)throw Error("Assertion failed: The locator should have been registered");M(a,n)}return n};for(const t of e.storedBuildState.keys()){const n=e.storedPackages.get(t);if(!n)throw Error("Assertion failed: The locator should have been registered");if(!C.includes(n.name))continue;let r;if("pnp"!==H)throw Error(`The nodeLinker ${H} is not supported for isolated Nix builds`);r=s.ppath.relative(e.cwd,s.ppath.join(U,a.structUtils.slugifyLocator(n),a.structUtils.getIdentVendorPath(n)));let o=n;if(a.structUtils.isVirtualLocator(o)){const{locatorHash:t}=a.structUtils.devirtualizeLocator(o),n=e.storedPackages.get(t);if(!n)throw Error("Assertion failed: The locator should have been registered");o=n}const i=a.structUtils.stringifyLocator(o),c=a.structUtils.stringifyLocator(n),l=`isolated.${p(i)}`;if(!R.has(o)){R.add(o);const e=[`pname = ${p(n.name)};`,`version = ${p(n.version)};`,`reference = ${p(o.reference)};`];if(A){const t=[...M(n)].sort().map((e=>`${p(e)}\n`)).join("");t&&e.push(`locators = [\n${t}];`)}const t=`override${V=n.name,V.split(/[^a-zA-Z0-9]+/g).filter((e=>e)).map((e=>{return(t=e).slice(0,1).toUpperCase()+t.slice(1);var t})).join("")}Attrs`;B.push(`${l} = optionalOverride (args.${t} or null) (mkIsolatedBuild { ${e.join(" ")} });`)}0===F.length&&F.push("# Copy in isolated builds."),F.push(`echo 'injecting build for ${n.name}'`,"yarn nixify inject-build \\",` ${p(c)} \\`,` \${${l}} \\`,` ${p(r)}`)}var V;if(F.length>0&&F.push("echo 'running yarn install'"),null==t.mode||0===C.length){const t=e.topLevelWorkspace.manifest.name,o=t?a.structUtils.stringifyIdent(t):"workspace",c=u("# This file is generated by running \"yarn install\" inside your project.\n# Manual changes might be lost - proceed with caution!\n\n{ lib, stdenv, nodejs, git, cacert, fetchurl, writeShellScript, writeShellScriptBin }:\n{ src, overrideAttrs ? null, ... } @ args:\n\nlet\n\n yarnBin = @@YARN_BIN@@;\n\n cacheFolder = @@CACHE_FOLDER@@;\n lockfile = @@LOCKFILE@@;\n\n # Call overrideAttrs on a derivation if a function is provided.\n optionalOverride = fn: drv:\n if fn == null then drv else drv.overrideAttrs fn;\n\n # Simple stub that provides the global yarn command.\n yarn = writeShellScriptBin \"yarn\" ''\n exec '${nodejs}/bin/node' '${yarnBin}' \"$@\"\n '';\n\n # Common attributes between Yarn derivations.\n drvCommon = {\n # Make sure the build uses the right Node.js version everywhere.\n buildInputs = [ nodejs yarn ];\n # All dependencies should already be cached.\n yarn_enable_network = \"0\";\n # Tell node-gyp to use the provided Node.js headers for native code builds.\n npm_config_nodedir = nodejs;\n };\n\n # Comman variables that we set in a Nix build, but not in a Nix shell.\n buildVars = ''\n # Make Yarn produce friendlier logging for automated builds.\n export CI=1\n # Tell node-pre-gyp to never fetch binaries / always build from source.\n export npm_config_build_from_source=true\n '';\n\n#@@ IF COMBINED_DRV\n cacheDrv = stdenv.mkDerivation {\n name = \"yarn-cache\";\n buildInputs = [ yarn git cacert ];\n buildCommand = ''\n cp --reflink=auto --recursive '${src}' ./src\n cd ./src/\n ${buildVars}\n HOME=\"$TMP\" yarn_enable_global_cache=false yarn_cache_folder=\"$out\" \\\n yarn nixify fetch\n rm $out/.gitignore\n '';\n outputHashMode = \"recursive\";\n outputHash = \"@@COMBINED_HASH@@\";\n };\n#@@ ENDIF COMBINED_DRV\n#@@ IF INDIVIDUAL_DRVS\n # Create derivations for fetching dependencies.\n cacheDrvs = let\n in lib.mapAttrs (locator: { filename, hash }: stdenv.mkDerivation {\n name = lib.strings.sanitizeDerivationName locator;\n buildInputs = [ yarn git cacert ];\n buildCommand = ''\n cd '${src}'\n ${buildVars}\n HOME=\"$TMP\" yarn_enable_global_cache=false yarn_cache_folder=\"$TMP\" \\\n yarn nixify fetch ${lib.escapeShellArg locator}\n # Because we change the cache dir, Yarn may generate a different name.\n mv \"$TMP/$(sed 's/-[^-]*\\.[^-]*$//' <<< \"$outputFilename\")\"-* $out\n '';\n outputFilename = filename;\n outputHash = hash;\n }) cacheEntries;\n\n # Create a shell snippet to copy dependencies from a list of derivations.\n mkCacheBuilderForDrvs = drvs:\n writeShellScript \"collect-yarn-cache\" (lib.concatMapStrings (drv: ''\n cp --reflink=auto ${drv} '${drv.outputFilename}'\n '') drvs);\n#@@ ENDIF INDIVIDUAL_DRVS\n\n#@@ IF NEED_ISOLATED_BUILD_SUPPRORT\n#@@ IF INDIVIDUAL_DRVS\n # Create a shell snippet to copy dependencies from a list of locators.\n mkCacheBuilderForLocators = let\n pickCacheDrvs = map (locator: cacheDrvs.${locator});\n in locators:\n mkCacheBuilderForDrvs (pickCacheDrvs locators);\n#@@ ENDIF INDIVIDUAL_DRVS\n\n # Create a derivation that builds a module in isolation.\n mkIsolatedBuild = { pname, version, reference, locators ? [] }: stdenv.mkDerivation (drvCommon // {\n inherit pname version;\n dontUnpack = true;\n\n configurePhase = ''\n ${buildVars}\n unset yarn_enable_nixify # plugin is not present\n '';\n\n buildPhase = ''\n mkdir -p .yarn/cache\n#@@ IF COMBINED_DRV\n cp --reflink=auto --recursive ${cacheDrv}/* .yarn/cache/\n#@@ ENDIF COMBINED_DRV\n#@@ IF INDIVIDUAL_DRVS\n pushd .yarn/cache > /dev/null\n source ${mkCacheBuilderForLocators locators}\n popd > /dev/null\n#@@ ENDIF INDIVIDUAL_DRVS\n\n echo '{ \"dependencies\": { \"${pname}\": \"${reference}\" } }' > package.json\n install -m 0600 ${lockfile} ./yarn.lock\n export yarn_global_folder=\"$TMP\"\n export yarn_enable_global_cache=false\n export yarn_enable_immutable_installs=false\n yarn\n '';\n\n installPhase = ''\n unplugged=( .yarn/unplugged/${pname}-*/node_modules/* )\n if [[ ! -e \"''${unplugged[@]}\" ]]; then\n echo >&2 \"Could not find the unplugged path for ${pname}\"\n exit 1\n fi\n\n mv \"$unplugged\" $out\n '';\n });\n#@@ ENDIF NEED_ISOLATED_BUILD_SUPPRORT\n\n # Main project derivation.\n project = stdenv.mkDerivation (drvCommon // {\n inherit src;\n name = @@PROJECT_NAME@@;\n\n configurePhase = ''\n ${buildVars}\n\n # Copy over the Yarn cache.\n rm -fr '${cacheFolder}'\n mkdir -p '${cacheFolder}'\n#@@ IF COMBINED_DRV\n cp --reflink=auto --recursive ${cacheDrv}/* '${cacheFolder}/'\n#@@ ENDIF COMBINED_DRV\n#@@ IF INDIVIDUAL_DRVS\n pushd '${cacheFolder}' > /dev/null\n source ${mkCacheBuilderForDrvs (lib.attrValues cacheDrvs)}\n popd > /dev/null\n#@@ ENDIF INDIVIDUAL_DRVS\n\n # Yarn may need a writable home directory.\n export yarn_global_folder=\"$TMP\"\n\n # Ensure global cache is disabled. Cache must be part of our output.\n touch .yarnrc.yml\n sed -i -e '/^enableGlobalCache/d' .yarnrc.yml\n echo 'enableGlobalCache: false' >> .yarnrc.yml\n\n # Some node-gyp calls may call out to npm, which could fail due to an\n # read-only home dir.\n export HOME=\"$TMP\"\n\n # running preConfigure after the cache is populated allows for\n # preConfigure to contain substituteInPlace for dependencies as well as the\n # main project. This is necessary for native bindings that maybe have\n # hardcoded values.\n runHook preConfigure\n\n@@ISOLATED_INTEGRATION@@\n\n # Run normal Yarn install to complete dependency installation.\n yarn install --immutable --immutable-cache\n\n runHook postConfigure\n '';\n\n buildPhase = ''\n runHook preBuild\n runHook postBuild\n '';\n\n installPhase = ''\n runHook preInstall\n\n # Move the package contents to the output directory.\n if grep -q '\"workspaces\"' package.json; then\n # We can't use `yarn pack` in a workspace setup, because it only\n # packages the outer workspace.\n mkdir -p \"$out/libexec\"\n mv $PWD \"$out/libexec/$name\"\n else\n # - If the package.json has a `files` field, only files matching those patterns are copied\n # - Otherwise all files are copied.\n yarn pack --out package.tgz\n mkdir -p \"$out/libexec/$name\"\n tar xzf package.tgz --directory \"$out/libexec/$name\" --strip-components=1\n\n cp --reflink=auto .yarnrc* \"$out/libexec/$name\"\n cp --reflink=auto ${lockfile} \"$out/libexec/$name/yarn.lock\"\n cp --reflink=auto --recursive .yarn \"$out/libexec/$name\"\n\n # Copy the Yarn linker output into the package.\n#@@ IF USES_PNP_LINKER\n cp --reflink=auto .pnp.* \"$out/libexec/$name\"\n#@@ ENDIF USES_PNP_LINKER\n#@@ IF USES_NM_LINKER\n cp --reflink=auto --recursive node_modules \"$out/libexec/$name\"\n#@@ ENDIF USES_NM_LINKER\n fi\n\n cd \"$out/libexec/$name\"\n\n # Invoke a plugin internal command to setup binaries.\n mkdir -p \"$out/bin\"\n yarn nixify install-bin $out/bin\n\n#@@ IF USES_NM_LINKER\n # A package with node_modules doesn't need the cache\n yarn cache clean\n#@@ ENDIF USES_NM_LINKER\n\n runHook postInstall\n '';\n\n passthru = {\n inherit nodejs;\n yarn-freestanding = yarn;\n yarn = writeShellScriptBin \"yarn\" ''\n exec '${yarn}/bin/yarn' --cwd '${overriddenProject}/libexec/${overriddenProject.name}' \"$@\"\n '';\n };\n });\n\n overriddenProject = optionalOverride overrideAttrs project;\n\n@@CACHE_ENTRIES@@\n@@ISOLATED@@\nin overriddenProject\n",{PROJECT_NAME:p(o),YARN_BIN:g,LOCKFILE:D,INDIVIDUAL_DRVS:A,COMBINED_DRV:!A,COMBINED_HASH:T,CACHE_FOLDER:I,CACHE_ENTRIES:j,ISOLATED:B.join("\n"),ISOLATED_INTEGRATION:h(" ",F.join("\n")),NEED_ISOLATED_BUILD_SUPPRORT:F.length>0,USES_PNP_LINKER:"pnp"===n.get("nodeLinker"),USES_NM_LINKER:"node-modules"===n.get("nodeLinker")}).replace(/\n\n\n+/g,"\n\n");if(await s.xfs.mkdirpPromise(s.ppath.dirname(d)),await s.xfs.writeFilePromise(d,c),n.get("generateDefaultNix")){const e=s.ppath.join(r,"default.nix"),t=s.ppath.join(r,"flake.nix");s.xfs.existsSync(e)||s.xfs.existsSync(t)||(await s.xfs.writeFilePromise(e,"# This is a minimal `default.nix` by yarn-plugin-nixify. You can customize it\n# as needed, it will not be overwritten by the plugin.\n\n{ pkgs ? import { } }:\n\npkgs.callPackage ./yarn-project.nix { } { src = ./.; }\n"),i.reportInfo(0,"A minimal default.nix was created. You may want to customize it."))}}n.get("enableNixPreload")&&s.xfs.existsSync(s.npath.toPortablePath("/nix/store"))&&await s.xfs.mktempPromise((async t=>{const n=["--add-fixed","sha512"],r=[];if(A)for(const[e,{cachePath:n,hash:a}]of O.entries()){const o=v(e),i=x(o,a);if(!s.xfs.existsSync(i)){const e=s.ppath.join(t,k(a).slice(0,7));await s.xfs.mkdirPromise(e);const i=s.ppath.join(e,o);await s.xfs.copyFilePromise(n,i),r.push(i)}}else{n.unshift("--recursive");const e=x("yarn-cache",T,{recursive:!0});if(!s.xfs.existsSync(e)){const e=s.ppath.join(t,"yarn-cache");await s.xfs.mkdirPromise(e);for(const[t,{cachePath:n}]of S.entries()){const r=s.ppath.join(e,t);await s.xfs.copyFilePromise(n,r)}r.push(e)}}try{const t=r.length;for(;0!==r.length;){const t=r.splice(0,100);await a.execUtils.execvp("nix-store",[...n,...t],{cwd:e.cwd,strict:!0})}0!==t&&i.reportInfo(0,A?`Preloaded ${t} packages into the Nix store`:"Preloaded cache into the Nix store")}catch(e){if("ENOENT"!==e.code)throw e}}))})(e,t)}},configuration:{enableNixify:{description:"If false, disables the Nixify plugin hook that generates Nix expressions",type:a.SettingsType.BOOLEAN,default:!0},nixExprPath:{description:"Path of the file where the project Nix expression will be written to",type:a.SettingsType.ABSOLUTE_PATH,default:"./yarn-project.nix"},generateDefaultNix:{description:"If true, a default.nix will be generated if it does not exist",type:a.SettingsType.BOOLEAN,default:!0},enableNixPreload:{description:"If true, cached packages will be preloaded into the Nix store",type:a.SettingsType.BOOLEAN,default:!0},individualNixPackaging:{description:"If true, generate one Nix derivation per package. If false, use a single derivation for the entire cache folder.",type:a.SettingsType.BOOLEAN,default:!1},isolatedNixBuilds:{description:"Dependencies with a build step that can be built in an isolated derivation",type:a.SettingsType.STRING,default:[],isArray:!0},installNixBinariesForDependencies:{description:"If true, the Nix output 'bin' directory will also contain executables for binaries defined by dependencies",type:a.SettingsType.BOOLEAN,default:!1}}};t=r})(),t}}; \ No newline at end of file diff --git a/PORTABLE_ARTIFACTS.md b/PORTABLE_ARTIFACTS.md new file mode 100644 index 0000000..0f4618a --- /dev/null +++ b/PORTABLE_ARTIFACTS.md @@ -0,0 +1,74 @@ +# Checked capability and component artifacts + +`mkCaminoArtifacts` is the fresh package builder. It regenerates candidate +bindings, typechecks the entry's dependency closure, bundles declared targets, +generates a process service when requested, and emits content manifests and +receipts. It has no migration step or package-defined shell hooks. + +```nix +# Inside a per-system flake output: +quixosPackages.checkedArtifacts = + { plan, generator, core, sharedRuntime, typescript, packageRevisionId }: + helpers.mkCaminoArtifacts { + inherit pkgs plan core sharedRuntime typescript packageRevisionId; + protocol = generator; + src = ./.; + portable = { + entry = "src/portable.ts"; + bindingOutput = "src/gen/qx.ts"; + registryExport = "registry"; + targets = [ "browser" "server" ]; + }; + }; +``` + +The handler entry exports `registry = createRegistry(handlers)` from the generated +bindings. One registry contains many exports. The builder always supplies a server +implementation for the generated process service. A service-only package instead +omits `portable` and supplies `service.entry`, an ordinary JavaScript process entry +using the versioned stdin/stdout protocol. Custom services own their lifecycle and +may hold external resources. This first helper supports JavaScript custom service +entries; other languages can emit the same checked artifact contract independently. + +Additional portable source dependencies can be provided as a locked `nodeModules` directory. +The workspace supplies the exact SDK and compiler; packages cannot replace those +with their own versions. Authored source symlinks and non-bundled portable imports +other than the SDK and server Node builtins are rejected. This is build closure +checking, not a purity proof or security sandbox. + +The core/Automerge bundle and Replicache bundle are built once. Browser modules +import the core through a content-addressed gateway URL, independent of package +identity. Each manifest covers the shared bytes it uses. The gateway retains its +workspace authentication and explicit file registry; it never interprets an HTTP +path as a Nix store path. Applications cannot import Replicache through the SDK. + +`checked-artifacts.nix` evaluates a package's `checkedArtifacts` output without +activation. During the checked Nix build, `loadCheckedArtifactWorld` verifies manifest bytes, the selected +generator identity, and exact regenerated binding sources. Receipts are consistency +evidence from a trusted Nix build, not cryptographic attestations of remote builds. +The build stores the checked plan, kernel/query programs, their digests and original +compiler provenance in its immutable candidate output. Runtime loading validates +that output's identities, artifact contents and supported runtime contracts. It +does not regenerate bindings or programs with the currently running compiler. +Nix owns rebuilding and cache reuse when a toolchain input changes. + +The root `camino-artifact-builds` check exercises actual reader/service outputs, +module and process execution, browser shared imports, rejection recovery, and +altered receipt rejection at the build boundary. The managed workspace host loads +the resulting checked candidate before activation. No adapter pretends this ABI +works in the old server dispatcher. + +Component packages add `components = { inherit (react) sdk runtime; entries; }`. +The ordinary execution candidate checker supplies the selected React build inputs +when the package declares components. Each entry maps a component ID to +`{ entry = "src/card.tsx"; export = "Card"; }`. See the +[React SDK](../camino-react/README.md) for bindings and hooks. CSS is emitted as a +checked artifact; local image/font imports are inlined. The helper rejects +undeclared external assets and substituted React/client dependencies. +Component-only packages produce no backend service. + +`camino-component-artifacts` checks bad subject/SDK/dependency/asset inputs and +proves that a component-only edit preserves a mixed package's service digest. +Run `nix run .#camino-component-source-qualification` from the monorepo to +materialize separate retained Git resources and compile an ordinary Nix candidate. +This is a source/build qualification harness; it does not deploy a workspace. diff --git a/artifacts.nix b/artifacts.nix new file mode 100644 index 0000000..8e2542a --- /dev/null +++ b/artifacts.nix @@ -0,0 +1,77 @@ +# Fresh artifact builder. Candidate plans and runtime dependencies are supplied by the workspace compiler. +{ + pkgs, + src, + plan, + protocol, + core, + sharedRuntime, + typescript ? core.dependencies, + packageRevisionId, + portable ? null, + remote ? null, + service ? { }, + nodeModules ? null, + components ? null, + group ? false, +}: +let + compiler = "${protocol}/libexec/quixos-protocol/execution-world.mjs"; + config = pkgs.writeText "camino-artifact-build.json" ( + builtins.toJSON { + inherit + plan + compiler + packageRevisionId + core + sharedRuntime + nodeModules + service + group + ; + components = + if components == null then + null + else + { + inherit (components) sdk runtime entries; + dependencies = components.sdk.dependencies; + bindingOutput = components.bindingOutput or "src/gen/client.ts"; + contracts = components.contracts or [ ]; + }; + source = src; + portable = + if portable == null then + null + else + { + inherit (portable) entry; + entries = portable.entries or { }; + registryExport = portable.registryExport or "registry"; + bindingOutput = portable.bindingOutput or "src/gen/qx.ts"; + targets = + portable.targets or [ + "browser" + "server" + ]; + }; + remote = + if remote == null then + null + else + { + inherit (remote) entry; + registryExport = remote.registryExport or "registry"; + bindingOutput = remote.bindingOutput or "src/gen/remote.ts"; + }; + node = "${pkgs.nodejs_24}/bin/node"; + tsc = "${typescript}/node_modules/typescript/bin/tsc"; + esbuild = "${pkgs.esbuild}/bin/esbuild"; + shell = "${pkgs.runtimeShell}"; + serviceAdapter = ./portable-service.mjs; + } + ); +in +pkgs.runCommand "camino-checked-artifacts" { passthru.buildConfig = config; } '' + ${pkgs.nodejs_24}/bin/node ${./.}/build-artifacts.mjs ${config} +'' diff --git a/assemble-package.mjs b/assemble-package.mjs new file mode 100644 index 0000000..56e226e --- /dev/null +++ b/assemble-package.mjs @@ -0,0 +1,103 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { pathToFileURL } from "node:url"; +import { buildService } from "./build-service.mjs"; + +const config = JSON.parse(await fs.readFile(process.argv[2], "utf8")); +const compiler = await import(pathToFileURL(config.compiler)); +const plan = JSON.parse(await fs.readFile(config.plan, "utf8")); +const pkg = plan.definition.packages.find((pkg) => pkg.revision === config.packageRevisionId); +if (!pkg || compiler.hexDigest(plan.definition) !== plan.digest) throw Error("Invalid package assembly context"); +const output = process.env.out; +if (!output) throw Error("Package assembly requires an output directory"); +const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const generatorDigest = digest(await fs.readFile(config.compiler)); +const implementations = [], + components = [], + files = new Map(), + ids = new Set(); +const recordFile = async (name, mediaType) => { + const bytes = await fs.readFile(path.join(output, compiler.artifactPath(name))); + return { path: name, bytes: bytes.length, digest: digest(bytes), mediaType }; +}; +await fs.mkdir(path.join(output, "share/quixos/generated"), { recursive: true }); +for (const root of config.groups) { + const manifest = compiler.parsePackageArtifacts( + JSON.parse(await fs.readFile(path.join(root, "share/quixos/package-artifacts.json"), "utf8")), + ); + if ( + manifest.packageRevisionId !== pkg.revision || + manifest.bindingDigest !== pkg.bindingDigest || + manifest.sourceDigest !== pkg.sourceDigest || + JSON.stringify(manifest.versions) !== JSON.stringify(plan.definition.versions) + ) + throw Error("Artifact group belongs to a different checked package"); + const receipt = JSON.parse(await fs.readFile(path.join(root, "share/quixos/build-receipt.json"), "utf8")); + const expected = compiler.expectedArtifactReceipt(plan, manifest, generatorDigest); + if (JSON.stringify(receipt) !== JSON.stringify(expected.receipt)) + throw Error("Artifact group has an invalid build receipt"); + const read = await compiler.artifactFileReader({ [pkg.revision]: root }); + for (const artifact of [...manifest.implementations, ...(manifest.components ?? [])]) { + const key = `${artifact.compatibility === "org.quixos.react.esm/1" ? "module" : "implementation"}:${artifact.id}`; + if (ids.has(key)) throw Error("Duplicate artifact group export " + key); + ids.add(key); + for (const file of artifact.files) { + const bytes = await read(pkg.revision, file.path, file.bytes); + if (bytes.length !== file.bytes || digest(bytes) !== file.digest) + throw Error("Modified group asset " + file.path); + const previous = files.get(file.path); + if (previous && JSON.stringify(previous) !== JSON.stringify(file)) + throw Error("Conflicting group asset " + file.path); + if (!previous) { + await fs.mkdir(path.dirname(path.join(output, file.path)), { recursive: true }); + await fs.writeFile(path.join(output, file.path), bytes, { mode: file.path.startsWith("bin/") ? 0o755 : 0o644 }); + files.set(file.path, file); + } + } + } + for (const [name, bytes] of expected.sources) { + const actual = await fs.readFile(path.join(root, name)); + if (!Buffer.from(bytes).equals(actual)) throw Error("Modified group type witness " + name); + await fs.writeFile(path.join(output, name), bytes); + } + implementations.push(...manifest.implementations); + components.push(...(manifest.components ?? [])); +} +if (implementations.filter((entry) => entry.compatibility === "quixos-service-v1").length > 1) + throw Error("A package can have only one process service owner"); +if (!implementations.some((entry) => entry.compatibility === "quixos-service-v1")) + await buildService({ + config: { ...config, service: {} }, + compiler, + pkg, + output, + coreServer: path.join(config.sharedRuntime, "core.mjs"), + sharedFiles: [...files.values()].filter((file) => file.path.startsWith("share/quixos/shared/")), + implementations, + recordFile, + }); +const manifest = compiler.parsePackageArtifacts({ + schemaVersion: 1, + packageRevisionId: pkg.revision, + bindingDigest: pkg.bindingDigest, + sourceDigest: pkg.sourceDigest, + versions: plan.definition.versions, + implementations, + ...(components.length ? { components } : {}), +}); +const { receipt, sources } = compiler.expectedArtifactReceipt(plan, manifest, generatorDigest); +for (const [name, bytes] of sources) { + const actual = await fs.readFile(path.join(output, name)); + if (!Buffer.from(bytes).equals(actual)) throw Error("Assembly lacks checked generated source " + name); +} +await fs.writeFile(path.join(output, "share/quixos/package-artifacts.json"), JSON.stringify(manifest, null, 2)); +await fs.writeFile(path.join(output, "share/quixos/build-receipt.json"), JSON.stringify(receipt, null, 2)); +// Check package coverage with the same public validator used at installation. +const packagePlan = { definition: { ...plan.definition, packages: [pkg] }, digest: "" }; +packagePlan.digest = compiler.hexDigest(packagePlan.definition); +await compiler.checkExecutionWorld( + packagePlan, + [manifest], + await compiler.artifactFileReader({ [pkg.revision]: output }), +); diff --git a/assemble-package.nix b/assemble-package.nix new file mode 100644 index 0000000..8550622 --- /dev/null +++ b/assemble-package.nix @@ -0,0 +1,29 @@ +{ + pkgs, + context, + artifacts, +}: +let + groups = map (artifact: artifact.caminoGroup or artifact) artifacts; + config = pkgs.writeText "camino-package-assembly.json" ( + builtins.toJSON { + inherit groups; + inherit (context) plan packageRevisionId sharedRuntime; + compiler = "${context.generator}/libexec/quixos-protocol/execution-world.mjs"; + node = "${pkgs.nodejs_24}/bin/node"; + esbuild = "${pkgs.esbuild}/bin/esbuild"; + shell = "${pkgs.runtimeShell}"; + serviceAdapter = ./portable-service.mjs; + } + ); +in +pkgs.runCommand "camino-package" + { + passthru = { + caminoGroups = groups; + assemblyConfig = config; + }; + } + '' + ${pkgs.nodejs_24}/bin/node ${./.}/assemble-package.mjs ${config} + '' diff --git a/build-artifacts.mjs b/build-artifacts.mjs new file mode 100644 index 0000000..8bf757f --- /dev/null +++ b/build-artifacts.mjs @@ -0,0 +1,193 @@ +import { installDependencies } from "./install-dependencies.mjs"; +import { buildService } from "./build-service.mjs"; +import { buildComponents } from "./build-components.mjs"; +/** Trusted Nix build driver. No package-defined build hooks run between generation and witnessing. */ +import fs from "node:fs/promises"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { builtinModules } from "node:module"; +import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +const config = JSON.parse(await fs.readFile(process.argv[2], "utf8")); +const compiler = await import(pathToFileURL(config.compiler)); +const plan = JSON.parse(await fs.readFile(config.plan, "utf8")); +const pkg = plan.definition.packages.find((p) => p.revision === config.packageRevisionId); +if (!pkg || compiler.hexDigest(plan.definition) !== plan.digest) throw Error("Invalid checked execution plan/package"); +const output = process.env.out; +if (!output) throw Error("Missing Nix output"); +const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const generatorDigest = digest(await fs.readFile(config.compiler)); +const runtime = + config.portable || config.remote + ? JSON.parse(await fs.readFile(path.join(config.sharedRuntime, "runtime.json"), "utf8")) + : { files: [] }; +const relative = (p) => compiler.artifactPath(p); +const implementations = []; +const generated = new Map(); +const recordFile = async (p, mediaType) => { + const bytes = await fs.readFile(path.join(output, relative(p))); + return { path: p, digest: digest(bytes), bytes: bytes.length, mediaType }; +}; +const copyRuntime = async () => { + await fs.mkdir(path.join(output, "share/quixos/shared"), { recursive: true }); + for (const file of runtime.files) { + relative(file.path); + const bytes = await fs.readFile(path.join(config.sharedRuntime, file.path)); + if (bytes.length !== file.bytes || digest(bytes) !== file.digest) throw Error("Shared runtime mismatch"); + await fs.writeFile(path.join(output, "share/quixos/shared", file.path), bytes); + } +}; +await copyRuntime(); +const sharedFiles = await Promise.all( + runtime.files.map((f) => recordFile(`share/quixos/shared/${f.path}`, f.mediaType)), +); +const coreFile = sharedFiles.find((f) => f.path.endsWith("/core.mjs")); +const coreURL = coreFile ? compiler.contentArtifactUrl(coreFile) : null; +const coreServer = path.join(config.sharedRuntime, "core.mjs"); +await fs.mkdir(path.join(output, "share/quixos/generated"), { recursive: true }); +const portable = config.portable; +for (const target of Object.keys(portable?.entries ?? {})) + if (!["browser", "server"].includes(target)) throw Error(`Unsupported portable entry target ${target}`); +for (const settings of [portable, config.remote].filter(Boolean)) + if (!/^[$A-Z_a-z][$\w]*$/.test(settings.registryExport)) + throw Error("Registry export must be a JavaScript identifier"); +const targets = portable ? [...new Set([...portable.targets, "server"])] : []; +const builds = targets.map((target) => ({ target, mode: target, settings: portable })); +if (config.remote) builds.push({ target: "server", mode: "remote", settings: config.remote }); +for (const { target, mode, settings } of builds) { + if (!["browser", "server"].includes(target)) throw Error(`Unsupported JS build target ${target}`); + const work = path.resolve(`work-${mode}`); + 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 package 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 }); + await installDependencies({ + destination: modules, + application: config.nodeModules, + selected: { "@quixos/camino-replica-core": config.core }, + reserved: ["@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" })); + const bindingPath = relative(settings.bindingOutput); + const source = + mode === "remote" + ? compiler.generateRemoteTypeScriptBindings(plan.definition.bindingSchema, pkg.revision) + : compiler.generatePortableTypeScriptBindings(plan.definition.bindingSchema, pkg.revision, { target }); + await fs.mkdir(path.dirname(path.join(work, bindingPath)), { recursive: true }); + await fs.writeFile(path.join(work, bindingPath), source); + generated.set(mode, { file: path.join(work, bindingPath), source }); + await fs.writeFile(path.join(output, "share/quixos/generated", `${mode}.ts`), source); + const entry = relative(settings.entries?.[target] ?? settings.entry); + execFileSync( + config.node, + [ + config.tsc, + "--noEmit", + "--strict", + "--skipLibCheck", + "false", + "--target", + "es2024", + "--lib", + "es2024,dom,esnext.disposable", + "--module", + "nodenext", + "--moduleResolution", + "nodenext", + entry, + ], + { cwd: work, stdio: "inherit" }, + ); + const bundlePath = + mode === "remote" ? "share/quixos/remote/server/module.mjs" : `share/quixos/portable/${target}/module.mjs`; + await fs.mkdir(path.dirname(path.join(output, bundlePath)), { recursive: true }); + const coreImport = target === "browser" ? coreURL : coreServer; + const metadata = path.join(work, "bundle-meta.json"); + execFileSync( + config.esbuild, + [ + entry, + "--bundle", + `--platform=${target === "browser" ? "browser" : "node"}`, + "--format=esm", + "--minify-whitespace", + "--target=es2024", + `--outfile=${path.join(output, bundlePath)}`, + `--metafile=${metadata}`, + `--alias:@quixos/camino-replica-core=${coreImport}`, + `--external:${coreImport}`, + ], + { cwd: work, stdio: "inherit" }, + ); + const meta = JSON.parse(await fs.readFile(metadata, "utf8")); + const built = Object.values(meta.outputs).find((o) => o.entryPoint); + if (!built?.exports.includes(settings.registryExport)) throw Error("Bundle lacks the declared registry export"); + // Portable modules must contain their ordinary source dependencies and share only the checked SDK. + if ( + Object.values(meta.outputs).some((o) => + o.imports.some( + (i) => + i.external && + i.path !== coreImport && + !(target === "server" && builtinModules.includes(i.path.replace(/^node:/, ""))), + ), + ) + ) + throw Error("Unexpected external import in portable module"); + const exports = pkg.exports + .filter((e) => + mode === "remote" + ? e.execution.kind === "remote" + : e.execution.kind !== "remote" && e.execution.targets.includes(target), + ) + .map((e) => e.id); + const body = { + id: `${mode}-module`, + target, + compatibility: "quixos-js-module-v1", + path: bundlePath, + ports: "quixos-transaction-ports-v1", + registryExport: settings.registryExport, + exports, + files: [await recordFile(bundlePath, "text/javascript"), ...sharedFiles], + }; + implementations.push({ ...body, contentDigest: compiler.implementationDigest(body) }); +} +const components = + config.group && !config.components + ? [] + : await buildComponents({ config, compiler, plan, pkg, output, recordFile, generated }); +if (!config.group || config.service.entry) + await buildService({ config, compiler, pkg, output, coreServer, sharedFiles, implementations, recordFile }); + +const manifest = compiler.parsePackageArtifacts({ + schemaVersion: 1, + packageRevisionId: pkg.revision, + bindingDigest: pkg.bindingDigest, + sourceDigest: pkg.sourceDigest, + versions: plan.definition.versions, + implementations, + ...(components.length ? { components } : {}), +}); +for (const { file, source } of generated.values()) + if ((await fs.readFile(file, "utf8")) !== source) throw Error("Generated bindings changed during build"); +const { receipt, sources } = compiler.expectedArtifactReceipt(plan, manifest, generatorDigest); +for (const [file, bytes] of sources) + if (!Buffer.from(bytes).equals(await fs.readFile(path.join(output, file)))) + throw Error("Receipt generation differs from compiled bindings"); +await fs.writeFile(path.join(output, "share/quixos/package-artifacts.json"), JSON.stringify(manifest, null, 2)); +await fs.writeFile(path.join(output, "share/quixos/build-receipt.json"), JSON.stringify(receipt, null, 2)); diff --git a/build-components.mjs b/build-components.mjs new file mode 100644 index 0000000..ba34594 --- /dev/null +++ b/build-components.mjs @@ -0,0 +1,262 @@ +import { installDependencies } from "./install-dependencies.mjs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); +export async function buildComponents({ config, compiler, plan, pkg, output, recordFile, generated }) { + const settings = config.components; + if (!settings) { + if (pkg.components?.length) throw Error("Declared components require checked component build settings"); + return []; + } + const declarations = config.group + ? (pkg.components ?? []).filter((entry) => Object.hasOwn(settings.entries, entry.id)) + : (pkg.components ?? []); + 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 }); + 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)};` + + (name === "@quixos/camino-react" ? "" : `export {default} from ${JSON.stringify(url)};`), + ); + sharedAliases.push("--alias:" + name + "=" + shim, "--external:" + url); + } + const bindingPath = compiler.artifactPath(settings.bindingOutput), + bindings = compiler.generateComponentClientBindings(plan, pkg.revision), + 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 }); + // 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"); + const contracts = plan.definition.workspace.interfaceImports.filter( + (iface) => + iface.revisionId === companion.interfaceRevisionId || + iface.application?.definitionId === companion.interfaceRevisionId, + ); + 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) { + if ( + !(iface.template?.members ?? iface.members).some( + (member) => member.kind === "module" && member.id === memberId, + ) + ) + throw Error("Props companion names an unknown module member"); + 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`, + ); + const components = []; + 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"); + const key = digest(Buffer.from(id)).slice(0, 24), + directory = "share/quixos/components/" + key; + const witness = path.join(work, "component-check-" + key + ".tsx"); + const module = pkg.modules.find((module) => module.id === id); + await fs.writeFile( + witness, + `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" + ? `import type {ComponentProps} from "@quixos/camino-react";declare const subject:ComponentProps<${JSON.stringify(declaration.subject)}>['subject'];const mount = ;\n` + : ""), + ); + execFileSync( + config.node, + [ + config.tsc, + "--noEmit", + "--strict", + "--skipLibCheck", + "false", + "--target", + "es2024", + "--lib", + "es2024,dom,dom.iterable,esnext.disposable", + "--module", + "nodenext", + "--moduleResolution", + "nodenext", + "--jsx", + "react-jsx", + path.basename(witness), + assetPath, + propsPath, + ], + { cwd: work, stdio: "inherit" }, + ); + const metadata = path.join(work, "component-meta-" + key + ".json"); + 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`, + ); + await fs.mkdir(path.join(output, directory), { recursive: true }); + 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" }, + ); + const meta = JSON.parse(await fs.readFile(metadata, "utf8")), + outputs = Object.entries(meta.outputs); + 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, + compatibility: "org.quixos.react.esm/1", + path: directory + "/module.mjs", + exportName: entry.export, + sharedModules, + files, + }; + components.push({ ...body, contentDigest: compiler.componentArtifactDigest(body) }); + } + return components; +} diff --git a/build-react-contracts.mjs b/build-react-contracts.mjs new file mode 100644 index 0000000..0081175 --- /dev/null +++ b/build-react-contracts.mjs @@ -0,0 +1,95 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +const config = JSON.parse(await fs.readFile(process.argv[2], "utf8")); +const resource = JSON.parse(await fs.readFile(config.interface, "utf8")); +if (resource.kind !== "interface") throw Error("React props belong to an interface resource"); +const iface = resource.revision; +const output = process.env.out; +if (!output) throw Error("Missing Nix output"); +const work = path.resolve("contracts-source"); +const declarations = []; +await fs.cp(config.source, work, { recursive: true }); +const writable = async (directory) => { + await fs.chmod(directory, 0o755); + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isSymbolicLink()) throw Error("Contract source cannot contain symlinks"); + if (entry.isDirectory()) await writable(file); + else { + await fs.chmod(file, 0o644); + if (/\.d\.[cm]?ts$/.test(file)) declarations.push(path.relative(work, file)); + } + } +}; +await writable(work); +const packageFile = path.join(work, "package.json"); +const metadata = await fs.readFile(packageFile, "utf8").then(JSON.parse, (error) => { + if (error.code === "ENOENT") return {}; + throw error; +}); +await fs.writeFile(packageFile, JSON.stringify({ ...metadata, type: "module" })); +if (config.dependencies) await fs.symlink(config.dependencies, path.join(work, "node_modules")); +const members = Object.create(null); +let index = 0; +for (const [id, entry] of Object.entries(config.members)) { + const member = (iface.template?.members ?? iface.members).find((member) => member.id === id); + if ( + member?.kind !== "module" || + !["org.quixos.react.component/1", "org.quixos.react.subject-only/1"].includes(member.contract) + ) + throw Error("Props entry is not a React module member: " + id); + if ( + !/^[$A-Z_a-z][$\w]*$/.test(entry.export) || + path.isAbsolute(entry.entry) || + entry.entry.split("/").some((part) => part === ".." || !part) + ) + throw Error("Invalid props type entry"); + const filename = `props-${index++}.ts`; + const typeImport = JSON.stringify("./" + entry.entry.replace(/\.[cm]?tsx?$/, ".js")); + await fs.writeFile( + path.join(work, filename), + `import type {${entry.export} as Props} from ${typeImport}; +type Assert = T; +type ObjectProps = Assert; +type ReservedSubject = Assert<"subject" extends keyof Props ? false : true>; +${member.contract === "org.quixos.react.subject-only/1" ? "type SubjectOnly = Assert<{} extends Props ? true : false>;" : ""} +export type {Props};\n`, + ); + members[id] = { path: filename.replace(/\.ts$/, ".d.ts"), export: "Props" }; +} +await fs.mkdir(path.join(output, "types"), { recursive: true }); +if (!Object.keys(members).length) throw Error("React type companion declares no module members"); +execFileSync( + config.node, + [ + config.tsc, + "--declaration", + "--emitDeclarationOnly", + "--strict", + "--target", + "es2024", + "--module", + "nodenext", + "--moduleResolution", + "nodenext", + "--rootDir", + work, + "--outDir", + path.join(output, "types"), + ...Object.values(members).map((entry) => path.join(work, entry.path.replace(/\.d\.ts$/, ".ts"))), + ], + { cwd: work, stdio: "inherit" }, +); +// TypeScript emits declarations for implementation sources, but does not copy +// authored declarations which those emitted files may import. +for (const file of declarations) { + await fs.mkdir(path.dirname(path.join(output, "types", file)), { recursive: true }); + await fs.copyFile(path.join(work, file), path.join(output, "types", file)); +} +await fs.writeFile(path.join(output, "package.json"), JSON.stringify({ type: "module" })); +if (config.dependencies) await fs.symlink(config.dependencies, path.join(output, "node_modules")); +await fs.writeFile( + path.join(output, "react-contracts.json"), + JSON.stringify({ schemaVersion: 1, interfaceRevisionId: iface.revisionId, members }, null, 2), +); diff --git a/build-service.mjs b/build-service.mjs new file mode 100644 index 0000000..5e3ca09 --- /dev/null +++ b/build-service.mjs @@ -0,0 +1,88 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +export async function buildService({ + config, + compiler, + pkg, + output, + coreServer, + sharedFiles, + implementations, + recordFile, +}) { + const relative = compiler.artifactPath; + if (pkg.exports.length || config.service.entry) { + await fs.mkdir(path.join(output, "bin"), { recursive: true }); + await fs.mkdir(path.join(output, "share/quixos/service"), { recursive: true }); + let serviceEntry; + if (config.service.entry) { + if (!/\.m?js$/.test(config.service.entry)) + throw Error("Custom service entry must be JavaScript; use a separate typed/language build before packaging"); + serviceEntry = path.join(config.source, relative(config.service.entry)); + } else { + const modules = implementations.filter((implementation) => implementation.target === "server"); + if (pkg.exports.some((entry) => !modules.some((module) => module.exports.includes(entry.id)))) + throw Error("Generated service does not cover every package export; provide a custom service"); + if (!modules.length) throw Error("Generated service requires a checked registry"); + serviceEntry = path.resolve("service-entry.mjs"); + const imports = modules + .map( + (module, index) => + `import { ${module.registryExport} as registry${index} } from ${JSON.stringify(path.join(output, module.path))};`, + ) + .join("\n"); + await fs.writeFile( + serviceEntry, + imports + + `\nimport {servePortableRegistry} from ${JSON.stringify(config.serviceAdapter)}; +const registries = [${modules.map((_, index) => "registry" + index).join(",")}]; +const owners = new Map(); +for (const registry of registries) for (const id of registry.exports) { + if (owners.has(id)) throw Error("Duplicate registry export " + id); + owners.set(id, registry); +} +servePortableRegistry({exports: [...owners.keys()], invoke(request, channel, context) { + const registry = owners.get(request.exportId); + if (!registry) throw Error("Unknown export"); + return registry.invoke(request, channel, context); +}});\n`, + ); + } + const serviceModule = "share/quixos/service/main.mjs"; + execFileSync( + config.esbuild, + [ + serviceEntry, + "--bundle", + "--platform=node", + "--format=esm", + "--minify-whitespace", + "--target=es2024", + `--external:${coreServer}`, + `--outfile=${path.join(output, serviceModule)}`, + ], + { stdio: "inherit" }, + ); + await fs.writeFile( + path.join(output, "bin/service"), + `#!${config.shell}\nservice_root=\${0%/*}/..\nexec ${config.node} "$service_root/${serviceModule}" "$@"\n`, + { mode: 0o755 }, + ); + const serviceBody = { + id: "service", + target: "server", + compatibility: "quixos-service-v1", + path: "bin/service", + ports: "quixos-transaction-ports-v1", + exports: pkg.exports.map((e) => e.id), + files: [ + await recordFile("bin/service", "text/x-shellscript"), + await recordFile(serviceModule, "text/javascript"), + ...sharedFiles, + ], + }; + implementations.unshift({ ...serviceBody, contentDigest: compiler.implementationDigest(serviceBody) }); + } +} diff --git a/install-dependencies.mjs b/install-dependencies.mjs new file mode 100644 index 0000000..3481799 --- /dev/null +++ b/install-dependencies.mjs @@ -0,0 +1,53 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { createHash } from "node:crypto"; + +async function packages(directory) { + const result = new Map(); + for (const entry of await fs.readdir(directory)) { + if (entry.startsWith(".")) continue; + if (entry.startsWith("@")) { + for (const name of await fs.readdir(path.join(directory, entry))) + result.set(`${entry}/${name}`, path.join(directory, entry, name)); + } else result.set(entry, path.join(directory, entry)); + } + return result; +} +async function packageDigest(directory) { + const digest = createHash("sha256"); + async function visit(relative) { + for (const entry of (await fs.readdir(path.join(directory, relative), { withFileTypes: true })).sort((a, b) => + a.name.localeCompare(b.name), + )) { + if (entry.name === "node_modules") continue; + const name = path.join(relative, entry.name), + file = path.join(directory, name); + if (entry.isDirectory()) await visit(name); + else { + const bytes = await fs.readFile(file); + digest.update(JSON.stringify([name, bytes.length])); + digest.update(bytes); + } + } + } + await visit(""); + return digest.digest("hex"); +} +/** Merge at package granularity: a scope such as @types is not a reserved package. */ +export async function installDependencies({ destination, application, defaults = [], selected = {}, reserved = [] }) { + const entries = new Map(); + for (const root of defaults) for (const [name, file] of await packages(root)) entries.set(name, file); + for (const [name, file] of Object.entries(selected)) entries.set(name, file); + if (application) + for (const [name, file] of await packages(application)) { + if (reserved.includes(name)) { + const canonical = entries.get(name); + if (!canonical || (await packageDigest(file)) !== (await packageDigest(canonical))) + throw Error("Application dependencies cannot substitute selected React/client types or checked SDK: " + name); + } else entries.set(name, file); + } + for (const [name, file] of entries) { + await fs.mkdir(path.dirname(path.join(destination, name)), { recursive: true }); + await fs.symlink(file, path.join(destination, name)); + } +} diff --git a/portable-service.mjs b/portable-service.mjs new file mode 100644 index 0000000..812437e --- /dev/null +++ b/portable-service.mjs @@ -0,0 +1,171 @@ +/** Generated-service transport. The supervisor owns process isolation, deadlines and termination. + * stdout is reserved for this protocol; application diagnostics belong on stderr. */ +export function servePortableRegistry( + registry, + { maxBytes = 4 * 1024 * 1024, maxInvocations = 64, maxPortCalls = 10000 } = {}, +) { + const abi = "quixos-transaction-ports-v1"; + const active = new Map(); + const pending = new Map(); + let buffer = Buffer.alloc(0), + stopped = false; + const send = (message) => { + const encoded = JSON.stringify(message); + if (Buffer.byteLength(encoded) > maxBytes) throw Error("SERVICE_MESSAGE_LIMIT"); + // A slow or absent coordinator must not cause an unbounded stdout queue. + if (process.stdout.writableLength + Buffer.byteLength(encoded) > maxBytes * 2) throw Error("SERVICE_BACKPRESSURE"); + process.stdout.write(encoded + "\n"); + }; + const errorBody = (error) => ({ + code: typeof error?.code === "string" ? error.code.slice(0, 1024) : "PACKAGE_ERROR", + message: String(error?.message ?? error).slice(0, 4096), + }); + const rejectPorts = (id, reason) => { + for (const [key, call] of pending) + if (call.invocation === id) { + pending.delete(key); + call.reject(reason); + } + }; + const cancel = (id, reason) => { + const token = active.get(id); + if (token) { + token.cancelled = true; + token.controller.abort(reason); + } + rejectPorts(id, reason); + }; + const fatal = (error) => { + if (stopped) return; + stopped = true; + for (const id of active.keys()) cancel(id, error); + process.stderr.write(String(error?.message ?? error) + "\n"); + process.exitCode = 1; + process.stdin.destroy(); + }; + const receive = (message) => { + if (!message || message.abi !== abi) throw Error("SERVICE_ABI_MISMATCH"); + if (message.kind === "shutdown") { + for (const id of active.keys()) cancel(id, Error("SERVICE_SHUTDOWN")); + stopped = true; + process.stdin.destroy(); + return; + } + if (message.kind === "cancel") { + const token = active.get(message.invocation); + if (token && token.frame !== message.frame) throw Error("INVALID_CANCELLATION_FRAME"); + cancel(message.invocation, Error("INVOCATION_CANCELLED")); + // A completed result may already be in flight. Acknowledge without resurrecting its context. + if (!token) send({ abi, kind: "cancelled", invocation: message.invocation, frame: message.frame }); + return; + } + if (message.kind === "port-result") { + const key = JSON.stringify([message.invocation, message.sequence]); + const call = pending.get(key); + if (!call && (!active.has(message.invocation) || active.get(message.invocation).cancelled)) return; + if (!call || message.frame !== call.frame) throw Error("UNEXPECTED_PORT_RESULT"); + pending.delete(key); + if (message.status === "returned") call.resolve(message.output); + else if (message.status === "failed") + call.reject( + Object.assign(Error(String(message.error?.message ?? "Port failed")), { + code: String(message.error?.code ?? "PORT_FAILED"), + }), + ); + else throw Error("INVALID_PORT_RESULT"); + return; + } + if ( + message.kind !== "invoke" || + typeof message.invocation !== "string" || + typeof message.frame !== "string" || + !message.invocation || + !message.frame || + message.invocation.length > 4096 || + message.frame.length > 4096 + ) + throw Error("INVALID_INVOCATION"); + if (active.has(message.invocation) || active.size >= maxInvocations) throw Error("INVOCATION_LIMIT_OR_DUPLICATE"); + const token = { frame: message.frame, controller: new AbortController(), cancelled: false }; + active.set(message.invocation, token); + let sequence = 0; + const channel = (request) => { + if (active.get(message.invocation) !== token || token.cancelled) + return Promise.reject(Error("EXPIRED_INVOCATION")); + if (++sequence > maxPortCalls || pending.size >= maxPortCalls) return Promise.reject(Error("PORT_CALL_LIMIT")); + const key = JSON.stringify([message.invocation, sequence]); + return new Promise((resolve, reject) => { + pending.set(key, { invocation: message.invocation, frame: message.frame, resolve, reject }); + try { + send({ + abi, + kind: "port", + frame: message.frame, + invocation: message.invocation, + sequence, + portId: request.portId, + action: { ...request.action, ...(request.key === undefined ? {} : { key: request.key }) }, + input: request.input, + }); + } catch (error) { + pending.delete(key); + reject(error); + } + }); + }; + void registry + .invoke( + { exportId: message.exportId, workspace: message.workspace, receiver: message.receiver, input: message.input }, + channel, + Object.freeze({ signal: token.controller.signal, effect: message.effect }), + ) + .then( + (output) => { + if (active.get(message.invocation) === token && !token.cancelled) + send({ abi, invocation: message.invocation, status: "returned", output }); + }, + (error) => { + if (active.get(message.invocation) === token && !token.cancelled) + send({ abi, invocation: message.invocation, status: "failed", error: errorBody(error) }); + }, + ) + .catch(fatal) + .finally(() => { + if (active.get(message.invocation) === token) { + active.delete(message.invocation); + token.controller.abort(Error("INVOCATION_ENDED")); + rejectPorts(message.invocation, Error("INVOCATION_ENDED")); + if (token.cancelled && !stopped) + send({ abi, kind: "cancelled", invocation: message.invocation, frame: token.frame }); + } + }) + .catch(fatal); + }; + process.stdin.on("data", (bytes) => { + if (stopped) return; + try { + // Consume each line before retaining the remainder; combined transport chunks may contain many frames. + let offset = 0; + while (offset < bytes.length) { + const newline = bytes.indexOf(10, offset); + const end = newline < 0 ? bytes.length : newline; + if (buffer.length + end - offset > maxBytes) throw Error("SERVICE_MESSAGE_LIMIT"); + buffer = Buffer.concat([buffer, bytes.subarray(offset, end)]); + if (newline < 0) break; + receive(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer))); + buffer = Buffer.alloc(0); + offset = newline + 1; + if (stopped) break; + } + } catch (error) { + fatal(error); + } + }); + process.stdin.on("end", () => { + if (buffer.length) fatal(Error("TRUNCATED_SERVICE_MESSAGE")); + for (const id of active.keys()) cancel(id, Error("COORDINATOR_DISCONNECTED")); + }); + process.stdin.on("error", fatal); + process.stdout.on("error", fatal); + send({ abi, kind: "ready", exports: registry.exports }); +} diff --git a/quixos-package-helpers.nix b/quixos-package-helpers.nix index c042e22..356a7d4 100644 --- a/quixos-package-helpers.nix +++ b/quixos-package-helpers.nix @@ -846,6 +846,13 @@ let ); in { + mkCaminoReactPackage = import ./react-package.nix; + mkCaminoReactContracts = import ./react-contracts.nix; + mkCaminoYarnDependencies = import ./yarn-dependencies.nix; + mkCaminoYarnShell = import ./yarn-shell.nix; + mkCaminoTypeScriptPackage = import ./typescript-package.nix; + mkCaminoTypeScriptService = import ./typescript-service.nix; + assembleCaminoPackage = import ./assemble-package.nix; inherit mkQuixosPackageFlake mkTsPackageServer diff --git a/react-contracts.nix b/react-contracts.nix new file mode 100644 index 0000000..acd5b9b --- /dev/null +++ b/react-contracts.nix @@ -0,0 +1,21 @@ +{ + pkgs, + context, + src, + members, + dependencies ? null, +}: +let + config = pkgs.writeText "camino-react-contracts.json" ( + builtins.toJSON { + source = src; + interface = context.interface; + inherit members dependencies; + node = "${pkgs.nodejs_24}/bin/node"; + tsc = "${context.typescript}/node_modules/typescript/bin/tsc"; + } + ); +in +pkgs.runCommand "camino-react-contracts" { } '' + ${pkgs.nodejs_24}/bin/node ${./.}/build-react-contracts.mjs ${config} +'' diff --git a/react-package.nix b/react-package.nix new file mode 100644 index 0000000..e76ad74 --- /dev/null +++ b/react-package.nix @@ -0,0 +1,34 @@ +{ + pkgs, + context, + src, + modules, + bindingOutput ? "src/gen/client.ts", + dependencies ? null, +}: +let + group = import ./artifacts.nix { + inherit pkgs src; + inherit (context) + plan + core + sharedRuntime + typescript + packageRevisionId + ; + protocol = context.generator; + group = true; + nodeModules = dependencies; + components = { + inherit bindingOutput; + inherit (context.react) sdk runtime; + entries = modules; + contracts = context.react.contracts or [ ]; + }; + }; + package = import ./assemble-package.nix { + inherit pkgs context; + artifacts = [ group ]; + }; +in +package // { caminoGroup = group; } diff --git a/typescript-package.nix b/typescript-package.nix new file mode 100644 index 0000000..950dcbb --- /dev/null +++ b/typescript-package.nix @@ -0,0 +1,41 @@ +{ + pkgs, + context, + src, + entry, + targets ? [ + "browser" + "server" + ], + entries ? { }, + bindingOutput ? "src/gen/qx.ts", + dependencies ? null, +}: +let + group = import ./artifacts.nix { + inherit pkgs src; + inherit (context) + plan + core + sharedRuntime + typescript + packageRevisionId + ; + protocol = context.generator; + group = true; + nodeModules = dependencies; + portable = { + inherit + entry + entries + targets + bindingOutput + ; + }; + }; + package = import ./assemble-package.nix { + inherit pkgs context; + artifacts = [ group ]; + }; +in +package // { caminoGroup = group; } diff --git a/typescript-service.nix b/typescript-service.nix new file mode 100644 index 0000000..61ceea6 --- /dev/null +++ b/typescript-service.nix @@ -0,0 +1,30 @@ +{ + pkgs, + context, + src, + entry, + bindingOutput ? "src/gen/remote.ts", + dependencies ? null, + service ? { }, +}: +let + group = import ./artifacts.nix { + inherit pkgs src service; + inherit (context) + plan + core + sharedRuntime + typescript + packageRevisionId + ; + protocol = context.generator; + group = true; + nodeModules = dependencies; + remote = { inherit entry bindingOutput; }; + }; + package = import ./assemble-package.nix { + inherit pkgs context; + artifacts = [ group ]; + }; +in +package // { caminoGroup = group; } diff --git a/yarn-dependencies.nix b/yarn-dependencies.nix new file mode 100644 index 0000000..b444174 --- /dev/null +++ b/yarn-dependencies.nix @@ -0,0 +1,29 @@ +{ + pkgs, + src, + nodejs ? pkgs.nodejs_24, +}: +let + project = (pkgs.callPackage (src + "/yarn-project.nix") { inherit nodejs; }) { + inherit src; + overrideAttrs = _: { + buildPhase = "runHook preBuild; runHook postBuild"; + installPhase = '' + runHook preInstall + test -d node_modules + mkdir -p "$out" + cp -a node_modules/. "$out/" + runHook postInstall + ''; + }; + }; +in +project +// { + devShell = pkgs.mkShell { + packages = [ + nodejs + project.yarn-freestanding + ]; + }; +} diff --git a/yarn-shell.nix b/yarn-shell.nix new file mode 100644 index 0000000..7f99c11 --- /dev/null +++ b/yarn-shell.nix @@ -0,0 +1,22 @@ +{ pkgs }: +let + yarnCli = pkgs.fetchurl { + url = "https://repo.yarnpkg.com/4.18.0/packages/yarnpkg-cli/bin/yarn.js"; + hash = "sha512-/Lhxb+fNDuzhQf/Bi5IZOp35IEwbqDGJwoiDUiP8C75kr0c7qw1emSen2utcryuwfrJ4fMkzjKBA6hJfKh8vfg=="; + }; + yarn = pkgs.writeShellScriptBin "yarn" '' + exec ${pkgs.nodejs_24}/bin/node ${yarnCli} "$@" + ''; +in +pkgs.mkShell { + packages = [ + pkgs.nodejs_24 + yarn + ]; + shellHook = '' + if [ ! -f .yarn/plugins/yarn-plugin-nixify.cjs ]; then + mkdir -p .yarn/plugins + cp ${./.yarn/plugins/yarn-plugin-nixify.cjs} .yarn/plugins/yarn-plugin-nixify.cjs + fi + ''; +}