diff --git a/README.md b/README.md index 78f9d8f..dc1c4bb 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,12 @@ Example `hp.cfg.override` for a nodejs application: } ``` +#### Code generator +``` +# Generate nodejs starter project +.\hpdevkit.ps1 gen nodejs starter +``` + ### Generate executable ```powershell cd windows diff --git a/docker/Dockerfile b/docker/Dockerfile index 8e7618b..b53d0bf 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,4 +22,7 @@ RUN chmod -R +x /build/scripts/*.sh FROM ubuntu:focal as runner COPY --from=builder /build/docker-extracted/usr/bin/docker /usr/bin/ COPY --from=builder /build/scripts/cluster.sh /usr/bin/cluster -COPY --from=builder /build/jq /usr/bin/jq \ No newline at end of file +COPY --from=builder /build/scripts/codegen.sh /usr/bin/codegen +COPY --from=builder /build/jq /usr/bin/jq + +COPY code-templates /code-templates \ No newline at end of file diff --git a/docker/code-templates/nodejs/starter/dist/hp.cfg.override b/docker/code-templates/nodejs/starter/dist/hp.cfg.override new file mode 100644 index 0000000..00e1e78 --- /dev/null +++ b/docker/code-templates/nodejs/starter/dist/hp.cfg.override @@ -0,0 +1,6 @@ +{ + "contract": { + "bin_path": "/usr/bin/node", + "bin_args": "index.js" + } +} \ No newline at end of file diff --git a/docker/code-templates/nodejs/starter/package.json b/docker/code-templates/nodejs/starter/package.json new file mode 100644 index 0000000..4ece906 --- /dev/null +++ b/docker/code-templates/nodejs/starter/package.json @@ -0,0 +1,13 @@ +{ + "name": "_projname_", + "version": "1.0.0", + "scripts": { + "build": "npx ncc build src/contract.js -o dist", + "build:prod": "npx ncc build src/contract.js --minify -o dist", + "start": "npm run build && hpdevkit deploy dist" + }, + "dependencies": { + "hotpocket-nodejs-contract": "0.5.3", + "@vercel/ncc": "0.34.0" + } +} \ No newline at end of file diff --git a/docker/code-templates/nodejs/starter/src/_projname_.js b/docker/code-templates/nodejs/starter/src/_projname_.js new file mode 100644 index 0000000..e4667bd --- /dev/null +++ b/docker/code-templates/nodejs/starter/src/_projname_.js @@ -0,0 +1,60 @@ +const fs = require('fs').promises; + +// This sample application writes and reads from a simple text file to serve user requests. +// Real-world applications may use a proper local database like sqlite. +const dataFile = 'datafile.txt' + +export class _projname_ { + sendOutput; // This function must be wired up by the caller. + + async handleRequest(userPubKey, message, isReadOnly) { + + // This sample application defines two simple messages. 'get' and 'set'. + // It's up to the application to decide the structure and contents of messages. + + if (message.type == 'get') { + + // Retrieved previously saved data and return to the user. + const data = await this.getData(); + await this.sendOutput(userPubKey, { + type: 'data_result', + data: data + }) + } + else if (message.type == 'set') { + + if (!isReadOnly) { + // Save the provided data into storage. + await this.setData(message.data); + } + else { + await this.sendOutput(userPubKey, { + type: 'error', + error: 'Set data not supported in readonly mode' + }) + } + + } + else { + await this.sendOutput(userPubKey, { + type: 'error', + error: 'Unknown message type' + }) + } + } + + async setData(data) { + // Hot Pocket subjects data on-disk to consensus. + await fs.writeFile(dataFile, data); + } + + async getData() { + try { + return (await fs.readFile(dataFile)).toString(); + } + catch { + console.log('Data file not created yet. Returning empty data.'); + return ''; + } + } +} \ No newline at end of file diff --git a/docker/code-templates/nodejs/starter/src/contract.js b/docker/code-templates/nodejs/starter/src/contract.js new file mode 100644 index 0000000..50bd552 --- /dev/null +++ b/docker/code-templates/nodejs/starter/src/contract.js @@ -0,0 +1,49 @@ +const HotPocket = require('hotpocket-nodejs-contract'); +const { _projname_ } = require('./_projname_'); + +// Hot Pocket smart contract is defined as a function which takes the Hot Pocket ExecutionContext as an argument. +// This function gets invoked every consensus round and whenever a user sends a out-of-concensus read-request. +async function contract(ctx) { + + // Create our application logic component. + // This pattern allows us to test the application logic independently of Hot Pocket. + const app = new _projname_(); + + // Wire-up output emissions from the application before we pass user inputs to it. + app.sendOutput = async (userPubKey, output) => { + // In Hot Pocket, each user is represented by their Ed25519 public key. + const user = ctx.users.find(userPubKey); + await user.send(output) + } + + // In 'readonly' mode, nothing our contract does will get persisted on the ledger. The benefit is + // readonly messages gets processed much faster due to not being subjected to consensus. + // We should only use readonly mode for returning/replying data for the requesting user. + // + // In consensus mode (NOT read-only), we can do anything like persisting to data storage and/or + // sending data to any connected user at the time. Everything will get subjected to consensus so + // there is a time-penalty. + const isReadOnly = ctx.readonly; + + // Process user inputs. + // Loop through list of users who have sent us inputs. + for (const user of ctx.users.list()) { + + // Loop through inputs sent by each user. + for (const input of user.inputs) { + + // Read the data buffer sent by user (this can be any kind of data like string, json or binary data). + const buf = await ctx.users.read(input); + + // Let's assume all data buffers for this contract are JSON. + // In real-world apps, we need to gracefully fitler out invalid data formats for our contract. + const message = JSON.parse(buf); + + // Pass the JSON message to our application logic component. + await app.handleRequest(userPubKey, message, isReadOnly); + } + } +} + +const hpc = new HotPocket.Contract(); +hpc.init(contract); \ No newline at end of file diff --git a/docker/scripts/codegen.sh b/docker/scripts/codegen.sh new file mode 100644 index 0000000..374356f --- /dev/null +++ b/docker/scripts/codegen.sh @@ -0,0 +1,40 @@ +#!/bin/bash +platform=$1 +apptype=$2 +projname=$3 + +templates_dir="/code-templates" +placeholder="_projname_" +output_dir=$CODEGEN_OUTPUT +usage="Usage: " + +if [ -z $templates_dir ] || [ -z $output_dir ] ; then + echo "Mandatory values missing." && exit 1 +fi + +[ -z $platform ] && echo "Platform is required. $usage" && exit 1 +[ -z $apptype ] && echo "App type is required. $usage" && exit 1 +[ -z $projname ] && echo "Project name is required. $usage" && exit 1 + +[ ! -d $templates_dir/$platform ] && echo "Invalid platform '$platform' specified." && exit 1 +[ ! -d $templates_dir/$platform/$apptype ] && echo "Invalid application type '$apptype' specified." && exit 1 +if ! [[ "$projname" =~ ^[a-z][a-z0-9_-]*$ ]]; then + echo "Invalid project name. Must be lowercase. Must start with a letter. Can only contain letters, numbers, dash and underscore." + exit 1 +fi + +mkdir -p $output_dir && rm -rf $output_dir/* +cp -r $templates_dir/$platform/$apptype/* $output_dir + +pushd $output_dir > /dev/null + +# Replace placeholder in all file contents. +find -type f -exec sed -i "s/${placeholder}/${projname}/g" {} \; + +# Rename files with placeholder name. +for f in $(find -type f -name "*${placeholder}*") +do + mv "$f" "$(echo "$f" | sed s/${placeholder}/${projname}/g)" +done + +popd > /dev/null \ No newline at end of file diff --git a/windows/hpdevkit.ps1 b/windows/hpdevkit.ps1 index 14ea3bd..d68c01c 100644 --- a/windows/hpdevkit.ps1 +++ b/windows/hpdevkit.ps1 @@ -12,9 +12,12 @@ $Network = "$($GlobalPrefix)_$($Cluster)_net" $ContainerPrefix = "$($GlobalPrefix)_$($Cluster)_node" $BundleMount = "$($VolumeMount)/contract_bundle" $DeploymentContainerName = "$($GlobalPrefix)_$($Cluster)_deploymgr" +$CodegenContainerName = "$($GlobalPrefix)_codegen" $ConfigOverridesFile = "hp.cfg.override" +$CodegenOutputDir = "/codegen-output" +$DefaultCodegenProject = "hpdevkitproject" -function DevKitContainer([string]$Mode, [string]$Name, [switch]$Detached, [switch]$AutoRemove, [switch]$MountSock, [switch]$MountVolume, [string]$Cmd) { +function DevKitContainer([string]$Mode, [string]$Name, [switch]$Detached, [switch]$AutoRemove, [switch]$MountSock, [switch]$MountVolume, [string]$EntryPoint, [string]$Cmd, [switch]$Status) { $Command = "docker $($Mode) -it" if ($Name) { @@ -35,17 +38,38 @@ function DevKitContainer([string]$Mode, [string]$Name, [switch]$Detached, [switc $Command += " --mount type=volume,src=$($Volume),dst=$($VolumeMount)" } + if ($EntryPoint) { + $Command += " --entrypoint $($EntryPoint)" + } + else { + $Command += " --entrypoint /bin/bash" + } + # Pass environment variables used by our scripts. $Command += " -e CLUSTER=$($Cluster) -e CLUSTER_SIZE=$($ClusterSize) -e DEFAULT_NODE=$($DefaultNode) -e VOLUME=$($Volume) -e NETWORK=$($Network)" $Command += " -e CONTAINER_PREFIX=$($ContainerPrefix) -e VOLUME_MOUNT=$($VolumeMount) -e BUNDLE_MOUNT=$($BundleMount) -e HOTPOCKET_IMAGE=$($InstanceImage)" - $Command += " -e CONFIG_OVERRIDES_FILE=$($ConfigOverridesFile)" + $Command += " -e CONFIG_OVERRIDES_FILE=$($ConfigOverridesFile) -e CODEGEN_OUTPUT=$($CodegenOutputDir)" $Command += " $($DevKitImage)" if ($Cmd) { - $Command += " /bin/bash -c '$($Cmd)'" + if ($EntryPoint) { + $Command += " $($Cmd)" + } + else { + $Command += " -c '$($Cmd)'" + } } - Invoke-Expression $Command + Invoke-Expression $Command 2>&1 | Write-Host + + if ($Status) { + if ($LASTEXITCODE -eq 0) { + return $True + } + else { + return $False + } + } } function ExecuteInDeploymentContainer([string]$Cmd) { @@ -76,53 +100,72 @@ function TeardownDeploymentCluster() { Function Deploy([string]$Path) { - InitializeDeploymentCluster + if ($Path) { + + InitializeDeploymentCluster - # If copying a directory, delete target bundle directory. If not create empty target bundle directory to copy a file. - $PrepareBundleDir = "" - if ((Get-Item $Path) -is [System.IO.DirectoryInfo]) { - $PrepareBundleDir = "rm -rf $($BundleMount)" + # If copying a directory, delete target bundle directory. If not create empty target bundle directory to copy a file. + $PrepareBundleDir = "" + if ((Get-Item $Path) -is [System.IO.DirectoryInfo]) { + $PrepareBundleDir = "rm -rf $($BundleMount)" + } + else { + $PrepareBundleDir = "mkdir -p $($BundleMount) && rm -rf $($BundleMount)/* $($BundleMount)/.??*" + } + ExecuteInDeploymentContainer -Cmd $PrepareBundleDir + docker cp $Path "$($DeploymentContainerName):$($BundleMount)" + + # Sync contract bundle to all instance directories in the cluster. + ExecuteInDeploymentContainer -Cmd "cluster stop ; cluster sync ; cluster start" + + if ($DefaultNode -gt 0) { + Write-Host "Streaming logs of node $($DefaultNode):" + ExecuteInDeploymentContainer -Cmd "cluster logs $($DefaultNode)" + } } else { - $PrepareBundleDir = "mkdir -p $($BundleMount) && rm -rf $($BundleMount)/* $($BundleMount)/.??*" - } - ExecuteInDeploymentContainer -Cmd $PrepareBundleDir - docker cp $Path "$($DeploymentContainerName):$($BundleMount)" - - # Sync contract bundle to all instance directories in the cluster. - ExecuteInDeploymentContainer -Cmd "cluster stop ; cluster sync ; cluster start" - - if ($DefaultNode -gt 0) { - Write-Host "Streaming logs of node $($DefaultNode):" - ExecuteInDeploymentContainer -Cmd "cluster logs $($DefaultNode)" + Write-Host "Please specify directory or file path to deploy." } } -Write-Host "Hot Pocket devkit launcher" +Function CodeGenerator() { + $ProjName = $args[2] + if (Test-Path -Path $ProjName) { + "Directory '$($ProjName)' already exists." + return + } + + if (DevKitContainer -Status -Mode "run" -Name $CodegenContainerName -EntryPoint "codegen" -Cmd "$($args[0]) $($args[1]) $($ProjName)") { + docker cp "$($CodegenContainerName):$($CodegenOutputDir)" ./$ProjName + Write-Host "Project '$($ProjName)' created." + } + docker rm "$($CodegenContainerName)" 2>&1 | Out-Null +} + $Command = $args[0] -$CommandError = "Invalid command. Expected: deploy | clean | start | stop | logs" +$CommandError = "Invalid command. Expected: deploy | clean | start | stop | logs | gen" if ($Command) { - Write-Host "command: $($Command) (cluster: $($Cluster))" - - if ($Command -eq "deploy") { - $Path = $args[1] - if ($Path) { - Deploy -Path $Path - } - else { - Write-Host "Please specify directory or file path to deploy." - } - } - elseif ($Command -eq "clean") { - TeardownDeploymentCluster - } - elseif ($Command -eq "logs" -OR $Command -eq "start" -OR $Command -eq "stop") { - DevKitContainer -Mode "run" -AutoRemove -MountSock -Cmd "cluster $($args)" + if ($Command -eq "gen") { + Write-Host "Hot Pocket devkit code generator" + CodeGenerator $args[1] $args[2] $args[3] } else { - Write-Host $CommandError + Write-Host "Hot Pocket devkit launcher" + Write-Host "command: $($Command) (cluster: $($Cluster))" + if ($Command -eq "deploy") { + Deploy -Path $args[1] + } + elseif ($Command -eq "clean") { + TeardownDeploymentCluster + } + elseif ($Command -eq "logs" -OR $Command -eq "start" -OR $Command -eq "stop") { + DevKitContainer -Mode "run" -AutoRemove -MountSock -EntryPoint "cluster" -Cmd "$($args)" + } + else { + Write-Host $CommandError + } } } else {