@s2script/sdk
v0.28.0
Published
s2script SDK — the builtin capability types (imported as @s2script/sdk/entity, …) plus the `s2s` CLI for building Source 2 / CS2 plugins.
Readme
@s2script/sdk
The s2script SDK — the TypeScript types and the s2s CLI for building
Source 2 / Counter-Strike 2 server plugins.
s2script is a plugin framework for Source 2 games, loaded via Metamod:Source. This package is what you develop against; the runtime ships with the server addon.
Quickstart
npx @s2script/sdk create my-plugin
cd my-plugin
npm run build # → dist/<id>.s2spCopy the .s2sp into addons/s2script/plugins/ on a running server and it loads immediately —
re-drop to hot-reload, delete to unload. No restart.
import { command, hook } from "@s2script/sdk";
import type { Client } from "@s2script/sdk";
export function OnPluginStart(): void {
command("hello", (cmd) => {
cmd.reply("hello from s2script");
});
hook.on("player_spawn", (ev) => {
console.log("spawned", ev.getPlayerSlot("userid"));
});
}
export function OnClientPostAdminCheck(client: Client): void {
client.chat("welcome");
}For an existing plugin with s2script.gamedata declarations, run
npx @s2script/sdk migrate engine-functions [plugin-dir]. The command analyzes the v1 declarations
and source references, reports ambiguities, and writes gamedata/functions.jsonc only when the
declared contract can be converted without loss. It refuses an existing destination unless you pass
--force. Its JSON report lists files actually changed and the package/source edits you must make
manually, including removal of the old s2script.gamedata field and authored engine permissions
when adopting the v2 file. A successful conversion does not verify that the v1 declaration listed
every native argument or that the resulting call works on the current game build. The command alone
does not provide the runtime compatibility facades for old source code. V1 entity hooks are refused
because their callbacks may receive a null receiver, which the current v2 receiver contract cannot
preserve. With --force, the report identifies the exact create-or-replace path; concurrent prior
existence cannot be established by the atomic rename.
This package is types-only — the engine injects the implementation at load time, and
s2s build marks @s2script/* external rather than bundling it. Capabilities are imported as
per-capability subpaths (@s2script/sdk/entity, @s2script/sdk/timers, @s2script/sdk/clients, …);
game-specific schema types ship separately in
@s2script/cs2.
Not every package is a plugin: npx @s2script/sdk create --library scaffolds a library —
build-time TypeScript a plugin bundles into its own .s2sp rather than something the host
loads on its own. Add a published one to a plugin with npx @s2script/sdk add <name>, which
vendors its code and types rather than adding an npm dependency (see
examples/library-package +
library-consumer).
Workspaces
A directory whose root package.json carries an s2script.workspace.plugins glob list — next to
npm's own workspaces field — is a workspace: a repo holding several plugins that build,
publish, and version together.
{
"name": "my-server-plugins",
"private": true,
"workspaces": ["plugins/*", "packages/*"],
"s2script": { "workspace": { "plugins": ["plugins/*"] } }
}npx @s2script/sdk create --workspace my-server-plugins
cd my-server-plugins
npx @s2script/sdk create shop # detects the workspace, writes plugins/shop (no per-plugin devDeps)
npm install # links every member into node_modules
npx @s2script/sdk build # every plugin, dependency order; --filter <pattern> to narrow
npx @s2script/sdk deploy # build + publish, skipping `private` and already-shipped versions
npx @s2script/sdk version # applies pending changesets, cascading sibling version rangesFinding no s2script.workspace marker above the target directory, every command behaves exactly as
it does today — workspace mode is opt-in by construction. s2s build <one-plugin-dir> (naming a
specific plugin rather than the root) always stays single-plugin mode even inside a workspace;
--filter is the workspace-mode way to narrow to a subset, by package name (@me/shop, @me/*) or
by path glob (plugins/sh*). --stamp-version <v> (build-only) rewrites every plugin's version
and the sibling ranges that would otherwise break.
Sibling interfaces need no .s2script/types/ copy. npm already symlinks every workspace member
into node_modules — declared as a dependency or not — so a consumer's import type { ShopAPI }
from "@me/shop" resolves straight to @me/shop's own published types file under
moduleResolution: "bundler", no config and no copy required. Contrast this with depending on a
registry package (s2s add @edge/admin-core): that pulls the producer's .d.ts down into your
own .s2script/types/<dep>/index.d.ts, because a registry producer isn't sitting in the same
checkout — a byte-copy (hashed into manifest.compiledAgainst, re-verified against the producer's
published hash at load) is the only way your build can see its shape at all, and it goes stale the
moment the producer's interface changes until you re-run s2s add. A workspace sibling's contract
can never go stale that way: the file your build resolves to is the producer's current contract,
on the same disk, in the same commit. If a plugin still carries a pre-migration
.s2script/types/<dep>/ copy after joining a workspace, the live sibling wins and the build warns
that the copy is now dead weight.
private: true means built, never published. Ordinary npm/Changesets semantics, not a new
field: s2s build builds every plugin in a workspace regardless of private, but s2s deploy
prints a per-plugin plan that skips a private: true package by name instead of shipping it. That's
what lets a workspace hold a large first-party or in-house plugin suite that must never reach the
public registry — build it, drop the .s2sps into your own server, never s2s deploy it. (s2s
deploy <dir> on a single private plugin outside a workspace refuses the same way.)
Bundles
A workspace whose plugins only make sense together (a game mode split into several plugins) can publish as one bundle: the registry lists it as a single entry, installs it with one command, and records which member versions each release pinned. Make the workspace root the bundle:
{
"name": "@edge/jailbreak",
"version": "1.0.0",
"workspaces": ["plugins/*"],
"s2script": {
"kind": "bundle",
"workspace": { "plugins": ["plugins/*"] },
"bundle": { "optional": ["@edge/jailbreak-gangperks"] }
}
}- Members are the workspace's non-private plugins, and must share the bundle's scope. A bundle
has no code of its own, so the root has no
mainand must not beprivate. s2s deployat the root publishes the members first, each claiming the bundle, then the bundle itself, pinning every member to the version just built. The plan shows it as the last row. A bundle version already published with different member versions is refused before anything uploads — bump it.s2s versionbumps the bundle whenever a member is released, by the largest member bump.s2s install @edge/jailbreakinstalls the required members (and whatever they depend on);--with @edge/jailbreak-gangperksadds an optional one.s2s addrefuses a bundle: depend on a member's interface instead.- A plugin already published before it joined a bundle must be republished at a new version from the bundle's workspace, so that its registry entry carries the claim.
The membership claim travels in the registry upload only — the .s2sp manifest the runtime loads
is unchanged, and the runtime has no notion of bundles.
Publishing
s2s deploy builds the package and publishes it to the registry. Its page on s2script.com
is filled in from what you already keep beside your code:
- README:
README.mdnext topackage.json(found the way npm finds one, up to 100,000 characters). Each deploy that carries one replaces the page's README. - Source link: package.json's standard
repositoryfield, in any npm form.{ "url": …, "directory": … }links straight into a monorepo directory. - Config: the
s2script.configblock, shown as a table of options with their defaults. Options marked"sensitive": truekeep their default off the page.
The README and repository travel with the deploy, not inside the .s2sp: the runtime never sees
them. A deploy without them leaves what the page already shows, so a README or link set on the
site stays until a deploy replaces it.
Docs
s2script.com/docs — getting started, guides, and the full API reference. Source and issues: GitHub.
License
MIT OR Apache-2.0
Typed plugin interfaces
Opt in with s2script.interfaceProtocol: 2, export a self-contained Contract with methods and
forwards: { EventName: Notification<Payload> }, then use publish(name, implementation) and
use(name) without caller-selected generics. s2s build generates the name association and checks
wire schemas; s2s add obtains only a plugin dependency's types. Include the generated
.s2script/interfaces.d.ts in existing projects' tsconfig files. Protocol 2 needs host API 3.
CJS module evaluation runs before the host opens its load window. Register publications,
subscriptions, binding maps and optional watches inside OnPluginStart. Exported handler
functions and imports can remain at module top level.
Inline handlers infer their payload from the provider:
import { use } from "@s2script/sdk/plugin";
export function OnPluginStart(): void {
use("@demo/racing").on("OnRunFinished", event => console.log(event.elapsedMs));
}Use an explicit binding map when a named local export has a different name from the provider forward. The returned subscription disposes the whole map:
import { bindForwards, command } from "@s2script/sdk";
export function OnRaceFinished(event: { elapsedMs: number }): void {
console.log(event.elapsedMs);
}
export function OnPluginStart(): void {
const racing = bindForwards("@demo/racing", { OnRunFinished: OnRaceFinished });
command.server("sm_disable_racing", () => racing.dispose());
}bindForwards requires a hard plugin dependency. Optional providers continue to attach through
watchOptional, where named local functions can be passed to the supplied service.on.
See the complete migration guide
for supported types, copying, dispatch, and compatibility limits.
Use Hook<Payload> for synchronous decisions and Transform<Payload, WritableKeys> for
validated shallow patches. Producers call dispatch; notifications use emit. All crossings
copy supported wire values. Listeners must return synchronously, and invalid/throwing listeners
contribute Continue. Nested crossings stop at 32 active calls. Retained optional services throw
InterfaceUnavailable after detach; reacquire through watchOptional on each provider generation
and own resources in its scope. tryUse is a one-time lookup.
For an existing consumer, declare the provider under s2script.pluginDependencies or
optionalPluginDependencies, run s2s add <provider>, include .s2script/interfaces.d.ts, and
rebuild with s2s build. Acquisition needs only the verified declaration. Rebuild and deploy the
producer and consumers together for protocol 2; API 3 hosts still accept legacy API-2/protocol-1
archives, while old API-2 hosts reject new API-3 builds. See the
acceptance workspace
for executable examples and the separate live verification sequence.
