Normally, dApps involve three parts:
- on-chain logic (Plutus or Plutarch scripts)
- off-chain logic (in our case, implemented using CTL)
- user interface
Building CTL-based JavaScript SDKs is the simplest way to connect user interfaces (most commonly, web apps) with off-chain logic. These SDKs expose app-specific APIs for web developers to plug into the user interface. SDKs are normally consumable as NPM packages.
Developers should start from reading this PureScript guide that shows how to call PureScript from JS. Our (older) PureScript version is using CommonJS modules and not ES modules, so import
statements should be replaced with require
.
Suppose we want to wrap a single Contract
into an interface to call it from JS with Nami wallet.
We have to expose functions to manage contract environment - initialization and finalization, as well as a config value we will use.
module Api where
import Prelude
import Contract.Config (ContractParams, testnetNamiConfig)
import Contract.JsSdk (mkContractEnvJS, stopContractEnvJS)
import Contract.Monad (ContractEnv, runContractInEnv)
import Control.Promise (Promise, fromAff)
import Data.Function.Uncurried (Fn1, mkFn1)
import Effect.Unsafe (unsafePerformEffect)
import Scaffold (contract) -- our contract
initialize :: Fn1 ContractParams (Promise ContractEnv)
initialize = mkContractEnvJS
finalize :: Fn1 ContractEnv (Promise Unit)
finalize = stopContractEnvJS
run :: Fn1 ContractEnv (Promise Unit)
run = mkFn1 \env ->
unsafePerformEffect $ fromAff $ runContractInEnv env contract
config :: ContractParams
config = testnetNamiConfig -- use Nami wallet
Fn1
-Fn10
types are wrappers that represent uncurried JavaScript functions with multiple arguments, andmkFn1
-mkFn10
are their constructors.Contract.JsSdk
is a module containing synonyms for someContract.Monad
functions, but adapted for use in JS SDKs.fromAff
convertsAff a
toEffect (Promise a)
, andunsafePerformEffect
removes theEffect
wrapper that is not needed on the JS side.
To prepare the module defined above for use from other NodeJS code, spago bundle-module
should be used:
spago bundle-module -m Api --to output.js
The resulting bundle will NOT include its NodeJS dependencies in the same file.
It can be distributed via NPM by pointing package.json
to it:
{
...
"main": "output.js",
...
"dependencies": {
// same dependencies as CTL itself uses should be put here
}
}
The module above can be imported like this from NodeJS:
const { initialize, config, run, finalize } = require('./output.js');
(async () => {
const env = await initialize(config);
try {
await run(env);
} finally {
await finalize(env);
}
})();
Notice that we used finally
to finalize - this is because a running contract environment would prevent the script from exiting otherwise. Please read this guide for info on how to manage the runtime environment correctly.
The recommended way to bundle CTL for the browser is to use WebPack.
We depend on WebPack's DefinePlugin
to conditionally load either NodeJS or browser variant of dependencies that have WebAssembly parts.
That means that CTL requires bundling it the same way when used as a dependency, as we do in development. If you intend to use another bundler, something like DefinePlugin
should be used to transform the import headers from this:
let lib;
if (typeof BROWSER_RUNTIME != "undefined" && BROWSER_RUNTIME) {
lib = require("@emurgo/cardano-serialization-lib-browser");
} else {
lib = require("@emurgo/cardano-serialization-lib-nodejs");
}
to only one of the import variants.
Our default WebPack config uses BROWSER_RUNTIME
environment variable to differentiate between two bundling options:
plugins: [
new webpack.DefinePlugin({
BROWSER_RUNTIME: !!process.env.BROWSER_RUNTIME,
}),
There's a claim that Vite bundler can also be used, although we don't officially support this method.
Webpack config contains entry
field, pointing to the main file of the app.
Assuming we want to use the example app from above, it can be populated like this:
import("./output.js").then(
async ({ initialize, config, run, finalize }) => {
const env = await initialize(config);
try {
await run(env);
} finally {
await finalize(env);
}
});
Note that import
returns a Promise
.
The config also contains some setup for output target:
output: {
path: path.resolve(__dirname, "dist"),
filename: "bundle.js",
},
But to actually build a page that can be opened in a browser, we use HtmlWebpackPlugin
:
new HtmlWebpackPlugin({
title: "ctl-scaffold",
template: "./index.html",
inject: false, // See stackoverflow.com/a/38292765/3067181
}),
The HTML page should contain this import, pointing to output bundle filename:
<script type="module" src="./bundle.js"></script>
type="module"
is required here.
experiments.syncWebAssembly
WebPack setting must be set to true
because CTL internal code expects it.
The whole webpage can be served with BROWSER_RUNTIME=1 webpack-dev-server --progress
or built with BROWSER_RUNTIME=1 webpack --mode=production