Modules

Creating a module

A module is TypeScript code the plugins of a project use - greeter.greet(player), without an import line, by the name the module gives itself. What it exports - its functions and types - is its API. It says what it is with defineModule, a project lists it in amxts.config.ts, and the server runs one instance of it. menu-core and config-core are modules. Code without state of its own on the server - a client for some service, helpers - can instead be a library: compiled into each plugin that imports it.

Starting one

npx amxts init --module @you/greeter             # a module in ./greeter
npx amxts init --module @you/greeter --natives   # with natives for Pawn plugins
cd greeter
npm install
npm test

It writes a working module - a greeter to replace with your own - with its playground project and a test that passes.

Layout

A module is an npm package:

greeter/
├── package.json
├── tsconfig.json       the editor config: extends @amxts/core/as/tsconfig.json
├── .oxlintrc.json      the lint rules: @antfu/eslint-config's, run by oxlint
├── .oxfmtrc.json       oxfmt, for the JSON and YAML files
├── README.md
├── README.ru.md
├── LICENSE
├── src/
│   ├── index.ts        the module: defineModule and the API
│   ├── types.ts        its public types (optional)
│   └── natives.ts      its natives for Pawn plugins (optional)
├── include/
│   └── greeter.inc     the Pawn include of those natives
├── playground/         a project with the module in it, to try it and test it
│   ├── package.json    "@you/greeter": "file:.."
│   ├── amxts.config.ts
│   └── plugins/
│       └── welcome.ts
└── test/
    └── greeter.test.ts

package.json points at them in its amxts field:

{
  "name": "@you/greeter",
  "version": "0.1.0",
  "type": "module",
  "exports": { ".": "./src/index.ts" },
  "types": "./src/index.ts",
  "amxts": {
    "module": "src/index.ts",
    "natives": "src/natives.ts",
    "include": "include/greeter.inc"
  },
  "files": ["README.md", "README.ru.md", "include", "src"]
}
  • module - the file plugins use. Required.
  • natives - the module's plugin, with natives for Pawn plugins (natives). Without it the build makes a plugin that only runs the module.
  • include - the Pawn include of those natives, shipped for Pawn plugins. The build writes it from the natives, unless it is a contract.
  • contract - true when the include already exists and is kept as it is: the build checks the natives against it instead of writing it (see three kinds of module).
  • testing - the module's test kit, for a module that needs more from the fake server than it has (testing a module).

Either way the module's plugin is its one instance on the server: every other plugin that imports the module calls that instance, with the same functions and types (shared modules).

The module

// src/index.ts
import { Player, 
print
} from "@amxts/core";
export interface GreeterOptions { /** What a player is greeted with. */
greeting
: string;
/** Greet only once a map. */
once
: boolean;
} export default
defineModule
<GreeterOptions>({
meta
: {
name
: "greeter",
configKey
: "greeter" },
defaults
: {
greeting
: "Hello",
once
: true },
imports
: [{
from
: "@you/greeter",
as
: "greeter" }],
setup
(
options
) {
greeting
=
options
.
greeting
;
once
=
options
.
once
;
}, }); declare module "@amxts/core" { interface ModuleOptions {
greeter
?: Partial<GreeterOptions>;
} } let
greeting
= "";
let
once
= true;
const
greeted
: string[] = [];
/** Greets a player - once a map, unless the project says otherwise. */ export function greet(
player
: Player) {
if (
once
&&
greeted
.
includes
(
player
.
name
)) return;
greeted
.
push
(
player
.
name
);
print
(
player
, `${
greeting
}, ${
player
.
name
}!`);
}

Write it as any plugin code: the same API, the same rules - with one difference: a module imports what it uses. Auto-imports are a project's, and a module is built in projects whose settings it does not know: @amxts/core, @amxts/core/natives, @amxts/core/fs and the rest of the core's API are imported by the package's name. Public types may live in src/types.ts, re-exported with export * from "./types"; the declare module "@amxts/core" block can go there too. State the module keeps is its own; other plugins change it through exported functions, not exported variables.

defineModule

defineModule is global, as defineConfig is in amxts.config.ts: it needs no import. What it takes:

fieldwhat
meta.namethe package's name without its scope: menu-core for @amxts/menu-core
meta.configKeythe key the module's options go under in amxts.config.ts
requiresmodule packages the module uses: they come along when a project lists this one, and load first
defaultsevery option's value when the project does not set it
importsthe name plugins use the module by without an import: [{ from: "@you/greeter", as: "greeter" }], the module as a namespace, or [{ from: "@you/greeter", name: "greeter" }], one of its exports - an object whose properties a plugin assigns (greeter.greeting = "Hi"); from is the module's own package
setup(options)runs once, in the module's plugin, when the server loads it - before any plugin's init

setup gets the defaults with the project's options over them: an object option is merged key by key, anything else is replaced. It is where the module applies them - and where it listens to the server (server.addEventListener), if it needs to.

Literals only
The build reads meta, requires, defaults and imports from the source, without running it. They are written as literals - strings, numbers, booleans, arrays and objects of them - and the options' type is named: defineModule<GreeterOptions>(...).

The declare module "@amxts/core" block adds the module's key to ModuleOptions, so the editor types a project's amxts.config.ts: greeter: { greting: "Hi" } is red there, with "Did you mean greeting?".

The kit

@amxts/core/kit is what a module's code needs besides @amxts/core. A plugin never needs it.

import { 
defineModule
, PawnFunction,
caller
,
request
} from "@amxts/core/kit";
defineModulethe same global defineModule, imported by name
caller()the plugin calling the native that runs now
callingPlugin(), onPluginStop(listener)the plugin whose call the module runs now, and when it stops (below)
PawnFunction.find(plugin, name)a public of a Pawn plugin, to call back: .call().int(id).text("KEY").run(); .buffer(size) and bufferText read what it wrote
publicFor(handler, key)a function of this plugin as the name of a public, for a Pawn plugin to call
createCellArray, cellArrayRows, pushCellArrayRow, destroyCellArray, cellsText, textCellsa Pawn plugin's Array:, read and filled row by row
showMenu(id, keys, text, title)show_menu for a menu of any length
menuColors(text)a text's colour tags (!y, !r) as the codes a menu draws
colorTags(text)text from Pawn - a dictionary's line, a Pawn plugin's argument - with its colour codes (\y, ^4) as tags
request(url, options)the server's network client as it is: HTTP, HTTPS, FTP, FTPS and SFTP, files of the game folder sent and received (below)

A plugin that stops since v0.2

A module keeps what plugins give it: a menu one makes, a function to call back. When that plugin is unloaded or reloaded - amxts dev reloads the plugin you saved - the new load gives it all again, and a function of the load that stopped answers nothing (false, 0, "" or null, said once in the console). So the module marks what a plugin gives with callingPlugin() and drops it in onPluginStop:

import { Player } from "@amxts/core";
import { 
callingPlugin
,
onPluginStop
} from "@amxts/core/kit";
interface Greeting {
text
: (
player
: Player) => string;
from
: number;
} let
greetings
: Greeting[] = [];
export function addGreeting(
text
: (
player
: Player) => string) {
greetings
.
push
({
text
,
from
:
callingPlugin
() });
}
onPluginStop
((
plugin
) => {
greetings
=
greetings
.
filter
(
greeting
=>
greeting
.
from
!=
plugin
);
});

callingPlugin() is a number for one load of the plugin whose call the module runs; a reload is a new number. It is 0 when no other plugin's call runs - the module's own natives, events and timers - and nothing is dropped for that.

Network requests since v0.2

request sends a request to an http:, https:, ftp:, ftps: or sftp: URL and gives what it ended with. It never rejects: a failure is errorKind, with the words in errorText and the protocol's reply code - FTP's 530, 550, SFTP's status - in replyCode.

import { 
request
} from "@amxts/core/kit";
async function backUp(
map
: string): Promise<void> {
const
result
= await
request
(`sftp://backup.example.com/maps/${
map
}.bsp`, {
user
: "admin",
keyFile
: "addons/amxmodx/data/backup_key",
upload
: true,
createDirs
: true,
file
: `maps/${
map
}.bsp`,
}); if (
result
.
errorKind
!= "")
console
.
error
(`backup of ${
map
}: ${
result
.
errorKind
} - ${
result
.
errorText
}`);
}
  • file is a path of the game folder, as in @amxts/core/fs: the download is written there, or the upload read from there, and the bytes never pass through the plugin - a map or a demo of any size. A path that leaves the game folder is refused.
  • A path ending in / is a folder: its listing, or its names one a line with list: true.
  • quote runs FTP or SFTP commands before the transfer (["DELE old.txt"], ["rename a.txt b.txt"]); with method: "HEAD" nothing is transferred.
  • errorKind is "login", "denied", "notFound", "refused", "timeout", "tls", "aborted" or "other" - or "" when there was an answer, an HTTP 404 included.
SSH keys
A key for SFTP is an RSA key in PEM - ssh-keygen -t rsa -m PEM. Keys in OpenSSH's own format, ECDSA and Ed25519 keys are not read yet (limitations).

Three kinds of module

Without natives

Only module in the amxts field. The build makes the module's plugin itself; TypeScript plugins use the module, Pawn plugins cannot call it.

With natives

// @filename: src/index.ts
import { Player } from "@amxts/core";

export function greet(
player
: Player) {
// the module above } // @filename: src/natives.ts import { Player,
plugin
} from "@amxts/core";
import * as
greeter
from "./index";
plugin
({
name
: "Greeter",
version
: "1.0.0",
author
: "you",
description
: "Greets players" });
/** Greets a player - for Pawn plugins. */ export function greeter_greet(
player
: Player) {
greeter
.
greet
(
player
);
}

Each export function is a native for Pawn plugins (natives). npx amxts build in the module's folder builds it and writes include/greeter.inc from the natives - the signatures from their types, the comments from their JSDoc:

/** Greets a player - for Pawn plugins. */
native greeter_greet(id);

Commit the include with the change: Pawn plugins compile against it.

An existing include: a contract

A module can serve the natives of an include that already exists - one Pawn plugins are compiled against. It keeps the include as it is, so compiled .amxx plugins load against the module unchanged, and says so in package.json:

"amxts": {
  "module": "src/index.ts",
  "natives": "src/natives.ts",
  "include": "include/mystats.inc",
  "contract": true
}

The build then does not write the include. It reads each native's signature from it - Float:, tags, & references, an out[], len buffer - and stops when an export does not match:

mystats.ts: export function mystats_get - parameter "extra": the include declares no argument for it
mystats.ts: mystats.inc declares mystats_get, and the plugin does not export it

A library since v0.2

A module whose code needs no instance of its own on the server - a client for a service, a protocol, helpers - can be a library: the build compiles it into each plugin that imports it, the way a bundler takes an npm library. There is no module plugin, no call between plugins, and nothing it exports has to cross one: async functions, classes, generics, Maps all work as in any TypeScript.

"amxts": {
  "module": "src/index.ts",
  "library": true
}

The official @amxts/ftp is one:

import { 
ftp
} from "@amxts/ftp";
const
client
= await
ftp
.
connect
("sftp://admin@example.com", {
password
});

A library has no defineModule - no options, no auto-import name - no natives, no include and no test kit: plugins import what it exports by the package's name. It is listed in amxts.config.ts's modules like any module (npx amxts module add does it), and may import modules itself.

Choose by state. Something every plugin must see the same - a menu, a config, one rule for the server - is a module with its one instance. Code each plugin can run on its own is a library: each plugin that imports it has its own copy, with its own top-level variables.

Before publishing

npx amxts check   # npm run check

in the module's folder checks that the package is ready - for CI before npm publish:

  • the amxts field points at files that are there;
  • the module file has defineModule with its meta.name;
  • README.md and LICENSE are there;
  • the natives compile, and the include is what npx amxts build writes from them - not stale after an edit of natives.ts; with "contract": true, that they match the include.
ok   "amxts" points at src/index.ts, src/natives.ts, include/greeter.inc
ok   defineModule: greeter, options under "greeter", plugins use it as greeter
ok   include/greeter.inc is up to date with 1 natives

Using it in a project

A project installs the module and lists it in amxts.config.ts, beside package.json:

// amxts.config.ts
export default 
defineConfig
({
modules
: ["@you/greeter"],
greeter
: {
greeting
: "Привет" },
});

and its plugins use it by the name it gives, without an import line:

// plugins/welcome.ts
server
.
addCommand
("/hello", ({
player
}) => greeter.greet(
player
));

import * as greeter from "@you/greeter" works too, and is how a plugin uses a module that gives no name.

npx amxts build builds the module's plugin as greeter.aot beside the project's plugins, and writes plugins.ini with the modules first - each after what it requires - then the project's plugins. Only a module some plugin uses is built: one the config lists and no plugin uses is left out, and the build says so. A module whose natives Pawn plugins call is kept with pawn (only what is used is built). The build stops, and says why, when a module is not installed, when one it requires is not installed, when an option has no default in the module, and when a plugin imports a module the config does not list.

Testing it

A module is tested on the fake server (testing), from its own folder, with npm test. setup() loads a project there as the build would lay it out on a server: the modules its plugins use, in load order, then its plugins.

// test/greeter.test.ts
import { 
expect
,
test
} from "bun:test";
import {
setup
} from "@amxts/core/test-utils";
test
("the playground's plugin greets whoever asks", async () => {
const
server
= await
setup
({
rootDir
: "playground" });
const
alice
=
server
.
join
("Alice");
alice
.
say
("/hello");
expect
(
alice
.
chat
).toContain("Hi, Alice!");
});
test
("a Pawn plugin greets through the native", async () => {
const
server
= await
setup
(); // the module's own folder: the module alone
const
bob
=
server
.
join
("Bob");
server
.
native
("greeter_greet",
bob
.
id
);
expect
(
bob
.
chat
).toContain("Hello, Bob!");
});

setup({ rootDir: "playground" }) is the playground project, with its amxts.config.ts; setup() in the module's own folder, which has no config, is the module alone, with its defaults. A module that calls natives the fake server does not answer ships a test kit for it - a module's test kit.

A module over another AMX Mod X module

A module can wrap a third-party AMX Mod X module or plugin, as the official resemiclip wraps ReSemiclip. Have it check on first use that what it needs is loaded, and write one clear line to the log instead of failing on a missing native. Say what it needs in its README, so a server owner knows what to install.

A module inside a project

Code a project's plugins share is a module without a package: a file modules/<name>.ts in the plugins folder, imported as ~/modules/<name>. It is compiled into every plugin that imports it - each gets its own copy, with its own state - unless the plugins folder has a plugin of the same name, <name>.ts. Then that plugin runs the module, and every other plugin calls it there, as with a package (shared modules). A folder modules/<name>/ beside amxts.config.ts with a package.json like the one above is a module package too, found by its name as if it were installed.

Sharing it

Publish the package to npm, then add it to the modules catalog. The catalog lists the modules of the registry amxts/modules - one YAML file per module - and amxts init and amxts module add offer the same list. Fork the registry, add modules/<name>.yml and open a pull request:

name: greeter
npm: "@you/greeter"
repo: you/greeter
description: Greets players by name as they join
category: gameplay
type: community
logo: assets/logo.svg
maintainers:
  - github: you

name is the module's name in the catalog: amxts module add greeter, and its page, /modules/greeter. category is one of ui, gameplay, admin, config, data, network and tools; logo, a path in the repository, is optional; description is in English. CI checks the entry, then a maintainer reviews it:

  • the repository is public on GitHub, with a README - the module's page in the catalog - and a LICENSE;
  • its package.json has the npm name, the amxts field, @amxts/core in peerDependencies as a version range the current core is in, and no preinstall, install or postinstall script;
  • the package is on npm, and its repository is the entry's;
  • the name looks like no other module's.

The registry's contributing guide says the rest. Once the pull request is merged, amxts offers the module within minutes and the site lists it after its next daily build.

A package the catalog does not list still installs with amxts module add - it is on npm - with a warning that it is not in the catalog.

Official and community modules

An official module is made by the amxts team: @amxts/<name> on npm, amxts/<name> on GitHub, type: official in its entry. Only an official module uses that scope and that organization. Every other module is a community module, type: community, under its author's name. Both pass the same checks; the catalog marks which is which.