Skip to main content

TypeScript Runtime

The game server embeds a JavaScript Virtual Machine (VM) which can be used to load and run custom logic specific to your game project. This is in addition to Go as supported programming languages to write your server code.

It’s useful to implement game code you would not want to run on the client, or trust the client to provide unchecked inputs on. You can think of this LayerG feature as similar to Lambda or Cloud Functions in other systems. A good use case is if you wanted to grant the user a reward each day that they play the game.

TypeScript is a superset of the JavaScript language. It allows you to write your code with types which helps to reduce bugs and unexpected runtime behavior. LayerG support for JavaScript has been built to directly consider the use of TypeScript for your code and is the recommended way to develop your JavaScript code.

You can learn more about how to write your JavaScript code in TypeScript in the official documentation.

Video Title

Keep in mind

The video tutorial above and written guide below offer different variations of how to setup your project. You can choose to follow one or the other, but not a combination of both.

Prerequisites

You will need to have these tools installed to work with TypeScript for your project:

  • Node v14 (active LTS) or greater.
  • Basic UNIX tools or knowledge on the Windows equivalents.

The TypeScript compiler and other dependencies will be fetched with NPM.

Restrictions

Before you start writing your server runtime code in TypeScript, there are some restrictions and considerations you should be aware of:

Registering functions

Due to the manner of the JavaScript function registration mapping to Go, all functions passed to the lgruntime.Initializer must be declared in the global scope rather than referenced from a variable.

// This is NOT valid

var YourRpcFunction = rpc("YourRpcFunction", function() {
// ...
});

// This is valid

function YourRpcFunction() {return rpc("YourRpcFunction", function() {
// ...
}); }

Compatibility

The JavaScript runtime is powered by the goja VM which currently supports the JavaScript ES5 spec. The JavaScript runtime has access to the standard library functions included in the ES5 spec.

There is no support for libraries that require Node, web/browser APIs, or native support (e.g. via Node).

You cannot call TypeScript functions from the Go runtime, or Go functions from the TypeScript runtime.

Global state

The JavaScript runtime code is executed in instanced contexts (VM pool). You cannot use global variables as a way to store state in memory or communicate with other JS processes or function calls.

Single threaded

The use of multi-threaded processing is not supported in the JavaScript runtime.

Sandboxing

The JavaScript runtime code is fully sandboxed and cannot access the filesystem, input/output devices, or spawn OS threads or processes.

This allows the server to guarantee that JS modules cannot cause fatal errors - the runtime code cannot trigger unexpected client disconnects or affect the main server process.

Initialize the project

These steps will set up a workspace to write all your project code to be run by the game server.

Define the folder name that will be the workspace for the project. In this case we’ll use “ts-project”.

mkdir -p ts-project/{src,build}
cd ts-project

Use NPM to set up the Node dependencies in the project. Install the TypeScript compiler.

npm init -y
npm install --save-dev typescript

Use the TypeScript compiler installed to the project to set up the compiler options.

npx tsc --init

You’ll now have a “tsconfig.json” file which describes the available options that are run on the TypeScript compiler. Once you’ve trimmed the commented out entries your file will look something like this:

{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}

Your “tsconfig.json” may have defaulted to targeting a later version than ES5. Currently we don’t support versions past ES5 so make sure that you are targeting the correct version. The other change we need to make is to remove the “module” line as we are going to be setting our own “outFile”, and these two are not compatible.

With those changes made, your file should now look like this:

{
"compilerOptions": {
"target": "es5",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}

Add this configuration option to the "compilerOptions" block:

"outFile": "./build/index.js",
See TypeScript Bundling with Rollup for an example not relying on the TypeScript complier, enabling you to bundle other node modules with your TypeScript code for LayerG.

Add the LayerG runtime types as a dependency to the project and configure the compiler to find the types.

npm i 'https://github.com/u2u-labs/go-layerg-common'

Add this configuration option to the "compilerOptions" block of the “tsconfig.json” file:

 "typeRoots": [
"./node_modules"
],

This completes the setup and your project should look similar to this layout:

.
├── build
├── node_modules
│ ├── layerg-runtime
│ └── typescript
├── package-lock.json
├── package.json
├── src
└── tsconfig.json

Develop code

We’ll write some simple code and compile it to JavaScript so it can be run by the game server.

All code must start execution from a function that the game server looks for in the global scope at startup. This function must be called "InitModule" and is how you register RPCs, before/after hooks, and other event functions managed by the server.

The code below is a simple Hello World example which uses the "Logger" to write a message. Name the source file “main.ts” inside the “src” folder. You can write it in your favorite editor or IDE.

let InitModule: lgruntime.InitModule =
function(ctx: lgruntime.Context, logger: lgruntime.Logger, lg: lgruntime.LayerG, initializer: lgruntime.Initializer) {
logger.info("Hello World!");
}

We can now add the file to the compiler options and run the TypeScript compiler.

{
"files": [
"./src/main.ts"
],
"compilerOptions": {
// ... etc
}
}

To compile the codebase:

npx tsc

Running the project

With Docker

The easiest way to run your server locally is with Docker.

To do this, create a file called Dockerfile.

FROM node:alpine AS node-builder

WORKDIR /backend

COPY package*.json .
RUN npm install

COPY tsconfig.json .
COPY src/*.ts src/
RUN npx tsc

FROM layergdev/layerg:latest

COPY --from=node-builder /backend/build/*.js /layerg/data/modules/build/
COPY local.yml /layerg/data/

Next create a docker-compose.yml file. For more information see the Install Layerg with Docker Compose documentation.

version: '3'
services:
postgres:
command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all
environment:
- POSTGRES_DB=layerg
- POSTGRES_PASSWORD=localdb
expose:
- "8080"
- "5432"
image: postgres:12.2-alpine
ports:
- "5432:5432"
- "8080:8080"
volumes:
- data:/var/lib/postgresql/data

layerg:
build: .
depends_on:
- postgres
entrypoint:
- "/bin/sh"
- "-ecx"
- >
/layerg/layerg migrate up --database.address postgres:localdb@postgres:5432/layerg &&
exec /layerg/layerg --config /layerg/data/local.yml --database.address postgres:localdb@postgres:5432/layerg
expose:
- "7349"
- "7350"
- "7351"
healthcheck:
test: ["CMD", "/layerg/layerg", "healthcheck"]
interval: 10s
timeout: 5s
retries: 5
links:
- "postgres:db"
ports:
- "7349:7349"
- "7350:7350"
- "7351:7351"
restart: unless-stopped

volumes:
data:

You will also need to create a configuration for layerg called local.yml. The runtime.js_entrypoint setting indicates to layerg to read the built javascript code.

console:
max_message_size_bytes: 409600
logger:
level: "DEBUG"
runtime:
js_entrypoint: "build/index.js"
session:
token_expiry_sec: 7200 # 2 hours
socket:
max_message_size_bytes: 4096 # reserved buffer
max_request_size_bytes: 131072

Now run the server with the command:

docker compose up

Without Docker

Install a Layerg binary stack for Linux, Windows, or macOS. When this is complete you can run the game server and have it load your code:

layerg --logger.level DEBUG --runtime.js_entrypoint "build/index.js"

Remember you need to build the build/index.js file by running npx tsc from the Terminal before you can execute the above command.

Confirming the server is running

The server logs will show this output or similar which shows that the code we wrote above was loaded and executed at startup.

{"level":"info","ts":"...","msg":"Hello World!","caller":"server/runtime_javascript_logger.go:54"}

Bundling with Rollup

The setup above relies solely on the TypeScript compiler. This helps to keep the toolchain and workflow simple, but limits your ability to bundle your TypeScript code with additional node modules.

Rollup is one of the options available to bundle node modules that don’t depend on the Node.js runtime to run within Layerg.

Configuring Rollup

When configuring your TypeScript project to use Rollup there are a few additional steps and alterations you will need to make to your project if you have followed the steps above.

The first thing you will need to do is install some additional dependencies that will allow you to run Rollup to build your server runtime code. These include Babel, Rollup, several of their respective plugins/presets and tslib.

To do this, run the following command in the Terminal, which will install the dependencies and add them to your package.json file as development dependencies:

npm i -D @babel/core @babel/plugin-external-helpers @babel/preset-env @rollup/plugin-babel @rollup/plugin-commonjs @rollup/plugin-json @rollup/plugin-node-resolve @rollup/plugin-typescript rollup tslib

With Rollup installed as a dev dependency of your project, you now need to modify the build script in package.json to run the rollup -c command instead of the tsc command. You should also add a type-check script that will allow you to verify your TypeScript compiles without actually emitting a build file.

package.json

{
...
"scripts": {
"build": "rollup -c",
"type-check": "tsc --noEmit"
},
...
}

Next, you must add the following rollup.config.js file to your project.

rollup.config.js

import resolve from '@rollup/plugin-node-resolve';
import commonJS from '@rollup/plugin-commonjs';
import json from '@rollup/plugin-json';
import babel from '@rollup/plugin-babel';
import typescript from '@rollup/plugin-typescript';
import pkg from './package.json';

const extensions = ['.mjs', '.js', '.ts', '.json'];

export default {
input: './src/main.ts',
external: ['layerg-runtime'],
plugins: [
// Allows node_modules resolution
resolve({ extensions }),

// Compile TypeScript
typescript(),

json(),

// Resolve CommonJS modules
commonJS({ extensions }),

// Transpile to ES5
babel({
extensions,
babelHelpers: 'bundled',
}),
],
output: {
file: 'build/index.js',
},
};

Followed by adding a babel.config.json file to your project.

babel.config.json

{
"presets": [
"@babel/env"
],
"plugins": []
}

There are also changes to the tsconfig.json file that must be made. Using Rollup simplifies the build process and means you no longer have to manually update the tsconfig.json file every time you add a new *.ts file to your project. Replace the contents of your existing tsconfig.json file with the example below.

tsconfig.json

{
"compilerOptions": {
"noImplicitReturns": true,
"moduleResolution": "node",
"esModuleInterop": true,
"noUnusedLocals": true,
"removeComments": true,
"target": "es5",
"module": "ESNext",
"strict": false,
},
"files": [
"./node_modules/layerg-runtime/index.d.ts",
],
"include": [
"src/**/*",
],
"exclude": [
"node_modules",
"build"
]
}

Next, you need to include a line at the bottom of your main.ts file that references the InitModule function. This is to ensure that Rollup does not omit it from the build.

main.ts

function InitModule(ctx: lgruntime.Context, logger: lgruntime.Logger, lg: lgruntime.LayerG, initializer: lgruntime.Initializer) {
logger.info('TypeScript module loaded.');
}

// Reference InitModule to avoid it getting removed on build
!InitModule && InitModule.bind(null);

Finally, you need to make a slight alteration to your Dockerfile to ensure you copy across the rollup.config.js and babel.config.json files. You must also change the RUN command to run your updated build command rather than using the TypeScript compiler directly. Replace the contents of your Dockerfile with the example below.

Dockerfile


FROM node:alpine AS node-builder

WORKDIR /backend

COPY package*.json .
RUN npm install

COPY . .
RUN npm run build

FROM layergdev/layerg:latest

COPY --from=node-builder /backend/build/*.js /layerg/data/modules/build/
COPY local.yml /layerg/data/

Building your module locally

Ensure you have all dependencies installed:

npm i

Perform a type check to ensure your TypeScript will compile successfully:

npm run type-check

Build your project:

npm run build

Running your module with Docker

To run Layerg with your custom server runtime code, run:

docker compose up

If you have made changes to your module and want to re-run it, you can run:

docker compose up --build layerg

This will ensure the image is rebuilt with your latest changes.

Error handling

JavaScript uses exceptions to handle errors. When an error occurs, an exception is thrown. To handle an exception thrown by a custom function or one provided by the runtime, you must wrap the code in a try catch block.

function throws(): void {
throw Error("I'm an exception");
}

try {
throws();
} catch(error) {
// Handle error.
logger.error('Caught exception: %s', error.message);
}

Unhandled exceptions in JavaScript are caught and logged by the runtime except if they are not handled during initialization (when the runtime invokes the InitModule function at startup), these will halt the server and should be handled accordingly.

// Error handling example for catching errors with InitModule.
function InitModule(ctx: lgruntime.Context, logger: lgruntime.Logger, lg: lgruntime.LayerG, initializer: lgruntime.Initializer) {
try {
initializer.registerRpc(rpcIdRewards, rpcReward);
} catch(error) {
logger.error('An error has occurred: %s', error.message);
}

try {
initializer.registerRpc(rpcIdFindMatch, rpcFindMatch);
} catch(error) {
logger.error('An error has occurred: %s', error.message);
}

try {
initializer.registerMatch(moduleName, {
matchInit,
matchJoinAttempt,
matchJoin,
matchLeave,
matchLoop,
matchTerminate,
matchSignal,
});
} catch(error) {
logger.error('An error has occurred: %s', error.message);
}

logger.info('JavaScript logic loaded.');
}

We recommend you use this pattern and wrap all runtime API calls for error handling and inspection.

try {
// Will throw an exception because this function expects a valid user ID.
lg.accountsGetId([ 'invalid_id' ]);
} catch(error) {
logger.error('An error has occurred: %s', error.message);
}

Returning errors to the client

When writing your own custom runtime code, you should ensure that any errors that occur when processing a request are passed back to the client appropriately. This means that the error returned to the client should contain a clear and informative error message and an appropriate HTTP status code.

Internally the Layerg runtime uses gRPC error codes and converts them to the appropriate HTTP status codes when returning the error to the client.

The Layerg TypeScript runtime defines the error codes in the lgruntime.Codes enum. You can use these to define your own custom lgruntime.Error objects. The following are some examples of errors you might define in your module.

const errBadInput: lgruntime.Error = {
message: 'input contained invalid data',
code: lgruntime.Codes.INVALID_ARGUMENT
};

const errGuildAlreadyExists: lgruntime.Error = {
message: 'guild name is in use',
code: lgruntime.Codes.ALREADY_EXISTS
};

Below is an example of how you would return appropriate errors both in an RPC call and in a Before Hook.

const createGuildRpc: lgruntime.RpcFunction = (ctx: lgruntime.Context, logger: lgruntime.Logger, lg: lgruntime.LayerG, payload: string): string | void  => {
// ... check if a guild already exists and set value of `alreadyExists` accordingly
const alreadyExists = true;

if (alreadyExists) {
throw errGuildAlreadyExists;
}

return JSON.stringify({ success: true });
};

const beforeAuthenticateCustom: lgruntime.BeforeHookFunction<lgruntime.AuthenticateCustomRequest> = (ctx: lgruntime.Context, logger: lgruntime.Logger, lg: lgruntime.LayerG, data: lgruntime.AuthenticateCustomRequest): void | lgruntime.AuthenticateCustomRequest => {
const pattern = new RegExp('^cid-([0-9]{6})$');

if (!pattern.test(data.account.id)) {
throw errBadInput;
}

return data;
}

Upgrading

Identifying your current version

When looking to upgrade your Layerg server you should begin by identifying the current version you are using. You can do this either by looking at your Dockerfile and the version tagged at the end of the image name (e.g. u2u-labs/layerg:latest) or by looking at your package.json (or package-lock.json if using the latest at the time of installation, which will give the exact commit hash) for the version of layerg-runtime (also known as Layerg Common). With the latter, once you have identified your current layerg-runtime version you can consult the compatibility matrix to identify the version of the Layerg binary you are using.

Identifying changes

With the current Layerg version established, you should look at the Server-Runtime Release Notes to see what changes have been made since the version you are currently on. This will help you identify any breaking changes or changes which may affect the custom server runtime code you have written.

Installing the latest version

Once you are sure which version of Layerg you want to upgrade to, you should update the version of layerg-runtime in your project. By consulting the compatibility matrix again you can identify which version of the layerg-runtime package you should install.

You can then install it as follows (where <version> is a github tag such as v1.23.0):

npm i https://github.com/u2u-labs/go-layerg-common#<version>

Common issues

TypeError: Object has no member

If you receive the above error message, chances are you are using a Layerg function that is not available in the version of Layerg that your server is running. This could happen if you install a later version of layerg-runtime package in your TypeScript project than is compatible with the version of the Layerg binary you are using. Check the compatibility matrix to ensure you are using compatible versions of Layerg and Layerg Common (layerg-runtime).

Sandboxing and restrictions

The TypeScript server runtime is provided as a sandboxed JavaScript VM via the Goja Go package. All TypeScript/JavaScript server runtime code that executes on the server has access only to the specific functionality exposed to it via Layerg.

There are several key restrictions to be aware of when developing your server runtime code using TypeScript:

  • All code must compile down to ES5 compliant JavaScript
  • Your code cannot interact with the OS in any way, including the file system
  • You cannot use any module that relies on NodeJS functionality (e.g. crypto, fs, etc.) as your code is not running in a Node environment

For specific compatibility issues present within Goja see the Goja known incompatibilities and caveats.

Global state

The TypeScript runtime cannot use global variables as a way to store state in memory.

Logger

The JavaScript logger is a wrapper around the server logger. In the examples you’ve seen formatting “verbs” (e.g. “%s”) in the output strings followed by the arguments that will replace them.

To better log and inspect the underlying Go structs used by the JavaScript VM you can use verbs such as “%#v”. The full reference can be found here.

Next steps