# Async VM Command Microservice Architecture & Code
## Architecture Overview
vm-command-service/
package.json
server.js # Express wiring, execution route, boot sequence
lib/
commandLoader.js # Load/unload command modules, register w/ control
plane
watcher.js # Debounced fs.watch wrapper for hot-reload
commands/ # Drop-in command modules live here
pray.js
**Data flow**
**Data flow**
.js lib/watcher.js (fs.watch) file :
added/changed ,
debounced event
<
<
lib/commandLoader.js
- bust require.cache
- validate module
- registry.set(verb,&)
,
POST /register
<
VM Control Plane (:8080)
Player action : POST /api/commands/:verb : registry lookup : execute(ctx)
<
{ output, mutations } : VM
**Design principles**
| Concern | Approach |
|---|---|
| Loading | `fs.readdirSync` + `require()`, one file = one verb |
| Reload | Native `fs.watch` on `./commands` with per-file debounce |
| Cache busting | `delete require.cache[require.resolve(p)]` before re-require
|
| Registration | Fire-and-forget POST to control plane; errors logged, not
fatal |
| Execution | Single dynamic route `/api/commands/:verb`, delegates to module
|
| Safety | Per-command timeout via `Promise.race`; failures return `fail_msg`
|
| Identity | Registration endpoint is derived from verb no per-file config |
---
## `package.json`
> Requires Node % 18 (uses built-in `fetch`).
## `lib/commandLoader.js`
## `lib/watcher.js`
> **Portability note:** `fs.watch` is fine on Linux (inotify). If you deploy
on macOS/Windows or edit files via tools that do atomic rename+replace on some
filesystems, swap in [`chokidar`](https://github.com/paulmillr/chokidar) it
exposes an `awaitWriteFinish` option that eliminates partial-read races. The
rest of the code is unchanged.
## `server.js`
## `commands/pray.js` example module
## How the loop behaves end-to-end
**1. First boot**
$ npm start
[loader] registered PRAY -> http://localhost:3000/api/commands/pray
[server] listening on 3000
[server] watching /app/commands
The control plane receives:
{
"verb": "PRAY",
"endpoint": "http://localhost:3000/api/commands/pray",
"start_msg": "You bow your head and begin to pray...",
"timeout": 2000,
"fail_msg": "Your prayers echo into the void. Nothing answers."
}
**2. A player prays**
POST /api/commands/pray
{ "player": {"id":"plr_1","blessings":0},
"room": {"id":"temple"},
"inventory": [{"id":"stone_idol"}] }
Response:
{
"ok": true,
"output": "The stone idol grows warm in your hands...",
"mutations": {
"memory": { "id": "plr_1", "blessings": 1, "last_blessed_at":
1717098423123 },
"room": { "id": "temple", "ambient": "A faint thread of incense
lingers..." }
}
}
**3. Dropping in a new verb from your shell bot**
scp shout.js deploy@vm:/app/commands/
Within ~200 ms (debounce), the service logs:
[loader] registered SHOUT -> http://localhost:3000/api/commands/shout
[hot-reload] shout.js
&and the control plane is auto-notified. Editing `pray.js` in place triggers
the same cycle: cache is busted, the module re-evaluated, the payload
re-POSTed **without restarting Express**.
---
## Extension points worth wiring next
1. **Unregister on delete.** Right now `onDelete` only drops the local entry.
Add a `POST /unregister` to the control plane so it can prune dead verbs.
2. **Schema validation of `ctx`.** A per-command `inputSchema` (Zod, Joi)
validated in the execution route keeps command logic defensive-free.
3. **Generation counter / versioning.** Include a monotonic `version` in the
registration payload so the control plane can ignore out-of-order reloads
during flurries.
4. **Swap `fs.watch` `chokidar`** if you ever deploy to macOS or write files
via tools that emit hundreds of events per save.
5. **Auth.** Wrap `/api/commands/:verb` behind a shared secret header
(`X-VM-Token`) matching what the control plane injects when it dispatches.