Skip to content

About

TypeScript bindings for the Steamworks SDK, generated from Valve's steam_api.json and called through koffi FFI. No native build step.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

steamwand

npm CI node license Discord

TypeScript bindings for the Steamworks SDK with no native build step. The binding layer is generated from steam_api.json, Valve's own machine-readable description of the flat C API, and called through koffi FFI. When a new SDK ships, you regenerate; when you need a function nobody wrapped yet, it is already there.

Why another Steamworks binding

Every existing Node binding makes you compile someone else's native code to add a function. greenworks is NAN-era C++ built on the pre-2014 RemoteStorage workshop API. steamworks.js is solid but every contribution means Rust, napi-rs, and a three-platform build matrix; adding one setter (SetItemUpdateLanguage) took two forked repos and a patch file. steamwand has no native code of its own. One generic FFI dependency, one generated TS layer, and the whole flat API surface: 25 interfaces, 807 functions, 191 callback structs with per-platform offset tables.

Install

pnpm add steamwand.js

Node 18+. Windows x64, Linux x64, Linux ARM64, and macOS (x64 and Apple silicon, one universal library) are wired up; the Steam client must be running. The steam_api redistributables ship in the package. koffi's own native binary comes as a separate @koromix/koffi-<os>-<arch> package next to koffi in node_modules, so a bundler or Electron packager rule that only keeps steamwand.js/** must also keep koffi/** and @koromix/**.

Documentation

Full docs are in the wiki: getting started, the core API, the curated workshop, stats, cloud, leaderboards, and lobbies, social, overlay, auth, system, capture, controllers, and DLC layers, the raw flat API, recipes, and troubleshooting. Every generated function also carries its original C signature, its out-buffer sizes, and a link to Valve's documentation, so hovering it in an editor is usually enough.

Use

import { init } from 'steamwand.js';

const steam = init({ appId: 480 });

// Curated workshop layer
const { fileId } = await steam.workshop.createItem();
await steam.workshop.submitUpdate(fileId, {
  title: 'My mod',
  description: 'Full description',
  contentPath: 'C:/mods/my-mod',
  tags: ['gameplay'],
  changeNote: 'English:\nFirst upload.\n\nDeutsch:\nErster Upload.',
}, { onProgress: (p) => console.log(p.status, p.bytesProcessed) });

// Per-language text, the thing that started this project
await steam.workshop.submitUpdate(fileId, {
  language: 'german',
  title: 'Mein Mod',
  description: 'Deutsche Beschreibung',
});

const item = await steam.workshop.getItem(fileId, { language: 'german' });

// Workshop item wiring: required DLC, collection children, extra previews,
// metadata and key/value tags, plus the running app's DLC list
await steam.workshop.addAppDependency(fileId, 1_234_567);
await steam.workshop.addDependency(9_876_543_210n, fileId); // collection, child
await steam.workshop.submitUpdate(fileId, {
  metadata: JSON.stringify({ buildId: 42 }),
  keyValueTags: { engineVersion: '1.14' },
  previewImages: ['C:/mods/my-mod/screenshot.png'],
  previewVideos: ['dQw4w9WgXcQ'],
});
const full = await steam.workshop.getItem(fileId, { children: true, additionalPreviews: true });
const dlc = steam.dlc.listDlc();

// Achievements, stats, cloud saves, leaderboards, lobbies: same treatment
steam.stats.unlock('ACH_WIN_ONE_GAME');
await steam.cloud.writeFile('save01.json', JSON.stringify({ level: 3 }));
const board = await steam.leaderboards.findOrCreate('Fastest Lap', 1, 3);
await steam.leaderboards.uploadScore(board.handle, 91_240);
const lobbyId = await steam.lobbies.create(2, 4);
steam.lobbies.setData(lobbyId, 'map', 'de_dust2');

// Friends, rich presence, overlay, auth tickets, system facts, Steam Input
const friends = steam.social.listFriends();
steam.social.setRichPresence('status', 'In the menu');
steam.overlay.activateInviteDialog(lobbyId);
const { hex } = await steam.auth.getWebApiTicket('my-backend');
if (steam.system.isSteamDeck()) steam.controllers.init();

// The raw generated layer, when the curated ones stop
const ticket = steam.apps.GetAppOwner();
steam.on('ItemInstalled_t', (data) => console.log('installed', data));
const found = await steam.async.userStats.FindLeaderboard('Fastest Lap');

steam.close();

Twelve curated layers (workshop, stats, cloud, leaderboards, lobbies, social, overlay, auth, system, capture, controllers, dlc) cover the flows most games need, with typed errors that carry the EResult. For everything else, steam.async wraps each of the 76 call-result functions as a promise, steam.on and steam.once give typed callbacks by struct name, and the out helpers make the flat API's out-buffers safe. 64-bit values (Steam ids, file ids, handles) are bigint everywhere. The dispatch pump checks every async result against the callback id the caller expected, so a mixed-up completion rejects instead of decoding garbage.

Workshop result handling

Workshop queries skip rows that Steam marks as file-not-found. Other row errors reject with a SteamResultError that retains the result code and row context. Failed row getters also reject. With additionalPreviews: true, every preview must be read successfully or the query rejects. Each returned preview has its native index for use in removePreviewIndexes. Without that option, additionalPreviews stays empty and does not describe the gallery.

Use getAppDependenciesResult when you need a complete set of required apps. Steam returns at most 32 IDs and provides no pagination for this call. The older getAppDependencies still returns an array for compatibility, but is deprecated because it discards the total count.

const requirements = await steam.workshop.getAppDependenciesResult(fileId);
if (!requirements.complete) {
  throw new Error(`Only ${requirements.appIds.length} of ${requirements.totalCount} required apps returned`);
}
// A complete snapshot is now available in requirements.appIds.

Upload paths must be absolute. Content must be a readable directory; main and additional previews must be readable regular files. Symlinks are followed and broken links fail. Validation errors identify the field and path, with the original filesystem error in cause where applicable. These checks do not validate image formats or sizes, inspect every content file, or prevent files from changing before Steam reads them.

Workshop change notes

language selects translated titles and descriptions. It does not select a change-note translation. The documented native update API, PublishedFile Web API, and RemoteStorage Web API provide no supported method to attach translated notes to the same history entry. Steamwand therefore does not expose that capability.

Send one changeNote with language-labeled sections together with the content upload, as in the example above. Do not submit once per language to translate that entry. Issue #15 reports that note-only and later translation submissions returned success without a visible note in its tested flow. A successful submission is not proof of visible change history. If the outcome is uncertain, inspect the item's Change Notes in Steam before retrying.

What is generated, what is not

scripts/generate.ts reads steam_api.json and emits src/generated/: enums, consts, one class per interface with its versioned accessor (SteamAPI_SteamUGC_v021, so an SDK bump is a regeneration, not an archaeology project), and struct layouts as explicit per-platform offset tables. Windows packs callback structs at 8 bytes, Linux and macOS at 4, CSteamID is pack(1); the generator encodes those rules and its output has been verified against steamworks-sys's bindgen layouts, 428 comparisons with zero differences. test/offsets.test.ts pins the workshop set so a future SDK bump cannot silently shift an offset.

Handwritten and small: the library loader, the dispatch pump, the struct decoder, and the twelve curated layers under src/api/. After close(), every call through the session throws instead of reaching the unloaded API.

The SDK itself is not in this repo and must not be committed; Valve's license does not allow redistributing the headers or steam_api.json. To regenerate, download the SDK from partner.steamgames.com, unpack it to sdk/ (gitignored apart from the STEAMWAND.md file), and run pnpm generate. sdk/STEAMWAND.md has the exact paths, the hash checks, and what to update on a version bump. sdk.lock.json records which SDK version and steam_api.json hash the committed output came from. Shipping the redistributable binaries (steam_api64.dll and friends) is normal practice and allowed.

Known limits

  • 12 of the 819 flat functions are skipped: 9 that take C function pointers (debug hooks, netsockets status callbacks) and 3 whose by-value struct the generator cannot prove safe (SteamIPAddress_t is a C union, SteamPartyBeaconLocation_t packs differently per platform). The Steam Input action-data calls, skipped before 0.3, are bound. The generator lists every remaining skip when it runs.
  • Structs containing C unions (SteamNetworkingIdentity and relatives) get no layout table, because steam_api.json cannot express unions and a guessed layout would read garbage. They are excluded loudly, not wrongly.
  • An FFI mistake crashes the process instead of throwing. If you embed this in something that must survive (a VS Code extension, an editor), run it in a child process. That is how the CK3 modding toolkit uses it.
  • Game server APIs are not wired up. There is no curated networking layer either: ISteamNetworkingSockets and ISteamNetworkingMessages take SteamNetworkingIdentity, one of the union structs above, so only the generated methods that avoid it are usable today.
  • The Steam overlay draws into the game's own renderer. Node has none, so an Electron app gets no overlay from this package; that needs a native hook.

Tests

Run pnpm typecheck, pnpm test, and pnpm build for the offline checks. The test command excludes live suites even when live-test environment variables are set. Typecheck also enforces the compile-time API contracts.

pnpm test:live runs live reads against a logged-in Steam client on app ID 480. pnpm test:live:write adds curated write tests, including a unique cloud file, private lobby, presence key, auth tickets, and local controller and screenshot-hook changes. It excludes Workshop uploads. Write tests require STEAM_TEST_STEAM_ID to match the logged-in account's SteamID64.

pnpm test:workshop separately tests a private item's creation, uploads, translations, metadata, previews, downloaded bytes, and deletion. It also requires an explicit STEAM_TEST_APP_ID. Set 480 explicitly for the Spacewar development example, or use an authorized app you control. Agreement acceptance remains a manual step. Live tests use real Valve services and attempt cleanup; they are not part of ordinary CI.

An unfinished Workshop run leaves a gitignored recovery record in .steamwand-live/workshop/ and blocks the next run. A timeout does not cancel a Steam upload. See the testing workflow for account and app setup, coverage limits, Valve's source documentation, and recovery steps.

License

MIT for the code here. The Steamworks redistributables in runtime/ are Valve's, under the Steamworks SDK Access Agreement.

About

TypeScript bindings for the Steamworks SDK, generated from Valve's steam_api.json and called through koffi FFI. No native build step.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages