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
pnpm dlx amxts init --module @you/greeter # a module in ./greeter
pnpm dlx amxts init --module @you/greeter --natives # with natives for Pawn plugins
cd greeter
pnpm install
pnpm test
yarn dlx amxts init --module @you/greeter # a module in ./greeter
yarn dlx amxts init --module @you/greeter --natives # with natives for Pawn plugins
cd greeter
yarn install
yarn test
bunx amxts init --module @you/greeter # a module in ./greeter
bunx amxts init --module @you/greeter --natives # with natives for Pawn plugins
cd greeter
bun install
bun run 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-truewhen 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:
| field | what |
|---|---|
meta.name | the package's name without its scope: menu-core for @amxts/menu-core |
meta.configKey | the key the module's options go under in amxts.config.ts |
requires | module packages the module uses: they come along when a project lists this one, and load first |
defaults | every option's value when the project does not set it |
imports | the 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.
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";
defineModule | the 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, textCells | a 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}`);
}
fileis 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 withlist: true. quoteruns FTP or SFTP commands before the transfer (["DELE old.txt"],["rename a.txt b.txt"]); withmethod: "HEAD"nothing is transferred.errorKindis"login","denied","notFound","refused","timeout","tls","aborted"or"other"- or""when there was an answer, an HTTP404included.
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
pnpm amxts check # npm run check
yarn amxts check # npm run check
bunx amxts check # npm run check
in the module's folder checks that the package is ready - for CI before
npm publish:
- the
amxtsfield points at files that are there; - the module file has
defineModulewith itsmeta.name; README.mdandLICENSEare there;- the natives compile, and the include is what
npx amxts buildwrites from them - not stale after an edit ofnatives.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.jsonhas thenpmname, theamxtsfield,@amxts/coreinpeerDependenciesas a version range the current core is in, and nopreinstall,installorpostinstallscript; - the package is on npm, and its
repositoryis 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.
Shared modules
A module is a package of TypeScript that plugins use - the official menu-core and config-core, or a module of your own. A module has one instance on the server, shared by every plugin that uses it:
Natives
A native is a function one AMX Mod X plugin or module offers the others. Natives go both ways here: a TypeScript plugin exports its own for Pawn plugins, and calls the natives of AMX Mod X, ReAPI and other modules where @amxts/core has nothing of its own.