openvibers/openvibe.games Commits

Run games-quickjs@1 pack mods in budgeted per-mod QuickJS sandboxes

OP OpenVibers committed 4 Oct 2026 · 5c34a9eb27 · 25 files, +1866 −20

Asked: T8 Games M3 — OpenVibe.Games: QuickJS server mods (plan T8, Games ADR-0007 milestones; M1 and M2 are merged: games-content@2 packs, the def-set handshake, Scraplandia as the first pack, touch controls). Security path: untrusted code runs on the game server. ONE pull request from current origin/main…
Harness job games-t8-games-m3-openvibe-games-183727 (plan T8).

Changes

ddiff --git a/README.md b/README.md
iindex 50d06ac..37e5bc5 100644
---- a/README.md
++++ b/README.md
@@@ -17,16 +17,16 @@ WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm
- the game: the authoritative simulation, the world, characters, inventories, skills, blueprints and
props, in PostgreSQL (ADR-0007 decision 5: schema from scratch, migrations under
`packages/persistence/migrations`, a write-behind flusher — the tick never awaits I/O)
--- the map editor and its assets, the mod registry (`games-content@2` packs, grants, audit) and the
-- `games.*` events
++- the map editor and its assets, the mod registry (`games-content@2` packs, grants, audit), the
++ `games-quickjs@1` server script mods (docs/mods.md) and the `games.*` events
- the game WebSocket protocol and the portal, `/play` and `/editor` pages
## Does not own
- identity (OpenVibe.Network: players are keyed by Network subjects), file storage beyond the local
copy (OpenVibe.Media mirrors map-editor assets), events delivery (OpenVibe.Events)
--- mod grants' authority (Network keeps them, `mods.grant.manage`) and executable mods (they wait for
-- OpenVibe.Host Stage C)
++- mod grants' authority (Network keeps them, `mods.grant.manage`) and installable executable mods
++ (they wait for OpenVibe.Host Stage C; script mods ship in the place's own packs)
## Depends on
@@@ -191,8 +191,12 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjects
requested capabilities; every runtime binding checks it at call time, a
revoked install or capability stops affecting the world on the next tick,
and install/grant/use/deny/revoke are audited (`mod_audit`). Trust tiers are
-- metadata only. Executable mods (scripts) are refused until sandboxed
-- execution exists in OpenVibe.Host (Stage C). API: `GET /api/v1/mods`,
++ metadata only. Executable mods are refused by the mods API until sandboxed
++ execution exists in OpenVibe.Host (Stage C); `games-quickjs@1` script mods
++ ship in the place's own packs instead, pinned by the def-set digest, and run
++ in a per-mod QuickJS sandbox only where `GAMES_SCRIPT_MODS=1` (budgets
++ `cpuMs` 2 per tick, `memoryMb` 8, `stackKb` 128 by default; over budget
++ disables the mod for the instance; host API and limits in docs/mods.md). API: `GET /api/v1/mods`,
`GET /api/v1/mods/:id` (public); `POST /api/v1/mods`,
`POST /api/v1/mods/:id/enable|disable|revoke|grants`,
`DELETE /api/v1/mods/:id/grants/:capability`, `GET /api/v1/mods/:id/audit`
ddiff --git a/STATUS.json b/STATUS.json
iindex 0bef2d6..2ea5c23 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -22,6 +22,7 @@
"persistent world, characters and props in PostgreSQL (write-behind flusher; the tick never awaits I/O)",
"map editor with assets mirrored to OpenVibe.Media",
"declarative mods (games-content@2: definitions merged into the content registry at boot, placements, announcements) with per-install grants and audit",
++ "server script mods (games-quickjs@1 in packs, pinned by the def-set digest): one QuickJS WASM sandbox per mod, cpuMs/memoryMb/stack budgets, intents only; off unless GAMES_SCRIPT_MODS=1",
"players keyed by Network subjects",
"games.* events through a transactional outbox",
"games.progress.summary user module",
ddiff --git a/apps/server/package.json b/apps/server/package.json
iindex e3df171..6281e29 100644
---- a/apps/server/package.json
++++ b/apps/server/package.json
@@@ -7,7 +7,7 @@
"dev": "tsx watch --env-file-if-exists=.env src/main.ts",
"start": "tsx src/main.ts",
"build": "tsc --build",
-- "bundle": "esbuild src/main.ts --bundle --platform=node --format=esm --target=node22 --external:pg --external:iovalkey --external:@electric-sql/pglite --outfile=dist/server.mjs --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\""
++ "bundle": "esbuild src/main.ts --bundle --platform=node --format=esm --target=node22 --external:pg --external:iovalkey --external:@electric-sql/pglite --external:quickjs-emscripten --outfile=dist/server.mjs --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\""
},
"dependencies": {
"@dimforge/rapier3d-deterministic-compat": "0.21.0",
@@@ -24,6 +24,7 @@
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.23.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0",
++ "quickjs-emscripten": "0.32.0",
"tsx": "^4.19.3",
"ws": "^8.18.1"
},
ddiff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts
iindex 77081bb..64d5047 100644
---- a/apps/server/src/config.test.ts
++++ b/apps/server/src/config.test.ts
@@@ -59,3 +59,12 @@ describe('loadConfig migrationsDir', () => {
expect(existsSync(join(migrationsDir, '0001_initial.sql'))).toBe(true)
})
})
++
++describe('loadConfig scriptMods', () => {
++ it('is off unless the place enables it', () => {
++ expect(loadConfig({}).scriptMods).toBe(false)
++ expect(loadConfig({ GAMES_SCRIPT_MODS: '0' }).scriptMods).toBe(false)
++ expect(loadConfig({ GAMES_SCRIPT_MODS: '1' }).scriptMods).toBe(true)
++ expect(loadConfig({ GAMES_SCRIPT_MODS: 'true' }).scriptMods).toBe(true)
++ })
++})
ddiff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
iindex 241e532..0e24f43 100644
---- a/apps/server/src/config.ts
++++ b/apps/server/src/config.ts
@@@ -63,6 +63,11 @@ export interface ServerConfig {
metricsLogSeconds: number
/** Multiplier on world-event cadences (tests shrink it). */
eventIntervalScale: number
++ /**
++ * GAMES_SCRIPT_MODS=1: run the def set's games-quickjs@1 script mods on this
++ * place. Off by default; the mods stay in the def set (and its digest) either way.
++ */
++ scriptMods: boolean
}
export interface PlatformConfig {
@@@ -155,6 +160,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
persistFlushSeconds: intEnv(env, 'PERSIST_FLUSH_SECONDS', 10),
metricsLogSeconds: intEnv(env, 'METRICS_LOG_SECONDS', 30),
eventIntervalScale: env.EVENT_INTERVAL_SCALE ? Number(env.EVENT_INTERVAL_SCALE) : 1,
++ scriptMods: env.GAMES_SCRIPT_MODS === '1' || env.GAMES_SCRIPT_MODS === 'true',
}
}
ddiff --git a/apps/server/src/game/platformIntegration.test.ts b/apps/server/src/game/platformIntegration.test.ts
iindex f85ca48..a550a9e 100644
---- a/apps/server/src/game/platformIntegration.test.ts
++++ b/apps/server/src/game/platformIntegration.test.ts
@@@ -11,12 +11,14 @@
* - lifecycle events commit with the state they describe and reach
* OpenVibe.Events (mock) with the games service token;
* - a restart restores the authoritative state: characters, world props,
-- * the world clock and the mod registry — without duplicating mod props.
++ * the world clock and the mod registry — without duplicating mod props;
++ * - a games-quickjs@1 script mod changes the world only through the intents
++ * the gameplay modules apply.
*/
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
--import { createContent } from '@openvibe/content'
++import { createContent, type ContentRegistry } from '@openvibe/content'
import { openPgStore, type PgPersistenceStore } from '@openvibe/persistence'
import { openTestDb } from '@openvibe/persistence/testing'
import { createRapierWorld, loadRapier, type RapierModule } from '@openvibe/physics/rapier'
@@@ -36,6 +38,8 @@ import { hashGuestKey } from '../net/guestIdentity.js'
import { CAP_ANNOUNCE, CAP_PLACE_PROP } from '../mods/contentPack.js'
import { ModRegistry, type ModActor } from '../mods/registry.js'
import { ModRuntime } from '../mods/runtime.js'
++import { ScriptModHost } from '../mods/script/host.js'
++import { createQuickJsSandboxFactory } from '../mods/script/quickjs.js'
import { sampleManifest } from '../mods/testFixtures.js'
import { ServerMetrics } from '../observability/metrics.js'
import { GameEventRecorder, outboxSink } from '../platform/gameEvents.js'
@@@ -82,8 +86,11 @@ interface Boot {
dispose(): void
}
--async function boot(platform: ReturnType<typeof createMockPlatform>): Promise<Boot> {
-- const content = createContent()
++async function boot(
++ platform: ReturnType<typeof createMockPlatform>,
++ opts: { content?: ContentRegistry; scripts?: ScriptModHost } = {},
++): Promise<Boot> {
++ const content = opts.content ?? createContent()
const physics = createRapierWorld(rapier)
// The store rides on the shared database; close() is never called (the instance is shared).
const store = openPgStore(db, { placeId: config.placeId })
@@@ -112,6 +119,7 @@ async function boot(platform: ReturnType<typeof createMockPlatform>): Promise<Bo
const game = new GameServer(config, world, store, new ServerMetrics(), log, {
events: recorder,
mods: runtime,
++ ...(opts.scripts ? { scripts: opts.scripts } : {}),
})
await game.load()
return {
@@@ -126,7 +134,7 @@ async function boot(platform: ReturnType<typeof createMockPlatform>): Promise<Bo
}
}
--function connect(game: GameServer) {
++function connect(game: GameServer, contentDigest = createContent().digest) {
const inbox: ServerMessage[] = []
let closed: { code: number; reason: string } | null = null
// The session system now requires an identity stamped on the connection
@@@ -145,7 +153,7 @@ function connect(game: GameServer) {
const msg = {
t: 'hello',
v: PROTOCOL_VERSION,
-- contentDigest: createContent().digest,
++ contentDigest,
slot: 0,
name: 'Ana',
appearance: defaultAppearance(),
@@@ -206,8 +214,24 @@ describe('Games on platform identity, events and mods (real server, restart)', (
expect(refused.closed).toEqual({ code: 4012, reason: 'no_identity' })
// Reject before loading a character or writing any world state. This also
-- // covers an older hello that has no digest at all.
-- for (const offered of [undefined, 'v2-00000000']) {
++ // covers an older hello that has no digest at all, and a client whose def
++ // set carries a games-quickjs@1 mod the server does not (the digest pins
++ // mod manifests and sources like every other definition).
++ const withMod = createContent([
++ {
++ mods: [
++ {
++ format: 'games-quickjs@1',
++ id: 'extra',
++ version: '1.0.0',
++ entry: 'extra.js',
++ source: 'function onTick() {}',
++ hooks: ['onTick'],
++ },
++ ],
++ },
++ ]).digest
++ for (const offered of [undefined, 'v2-00000000', withMod]) {
const stale = connect(a.game)
stale.conn.identity = { guestKeyHash: hashGuestKey(`stale-${offered ?? 'missing'}`) }
a.game.onMessage(stale.conn, {
@@@ -382,3 +406,53 @@ describe('Games on platform identity, events and mods (real server, restart)', (
c.dispose()
}, 60_000)
})
++
++describe('games-quickjs@1 script mods on the real server', () => {
++ const platform = createMockPlatform({ clients: { games: { secret: 's3cret', grants: [] } } })
++
++ it('a mod changes game state only through intents the gameplay modules apply', async () => {
++ const content = createContent([
++ {
++ mods: [
++ {
++ format: 'games-quickjs@1',
++ id: 'welcome-gift',
++ version: '1.0.0',
++ entry: 'welcome-gift.js',
++ hooks: ['onPlayerJoin'],
++ source: `function onPlayerJoin(e) {
++ e.player.name = 'not a rename';
++ game.emit({ kind: 'giveItem', playerId: e.player.id, item: 'scrap_metal', count: 3 });
++ game.emit({ kind: 'giveItem', playerId: e.player.id, item: 'no_such_item', count: 1 });
++ game.emit({ kind: 'announce', text: 'welcome ' + e.player.name });
++ }`,
++ },
++ ],
++ },
++ ])
++ const scripts = new ScriptModHost(content.allMods(), createQuickJsSandboxFactory(), log)
++ const b = await boot(platform, { content, scripts })
++ await scripts.start()
++ const p = connect(b.game, content.digest)
++ const { welcome } = await p.hello('guest-script-mod')
++ expect(welcome).toBeDefined()
++
++ const scrap = (m: ServerMessage) =>
++ m.t === 'inventory'
++ ? m.inv.slots.reduce((n, s) => n + (s.stack.def === 'scrap_metal' ? s.stack.count : 0), 0)
++ : 0
++ // A fresh character carries no scrap: all of it is the gift, through the game's inventory.
++ const inventories = p.inbox.filter((m) => m.t === 'inventory')
++ expect(inventories.length).toBeGreaterThan(0)
++ expect(scrap(inventories.at(-1)!)).toBe(3)
++ expect(p.inbox).toContainEqual({ t: 'announce', text: 'welcome not a rename' })
++ // The unknown item was refused by the game, not by trusting the mod.
++ expect(scripts.status()[0]).toMatchObject({
++ state: 'running',
++ intentsApplied: 2,
++ intentsRejected: 1,
++ })
++ scripts.dispose()
++ b.dispose()
++ })
++})
ddiff --git a/apps/server/src/game/systems/context.ts b/apps/server/src/game/systems/context.ts
iindex 6a95d21..699843e 100644
---- a/apps/server/src/game/systems/context.ts
++++ b/apps/server/src/game/systems/context.ts
@@@ -6,6 +6,7 @@ import type { EntityId, Logger, PlayerId } from '@openvibe/shared'
import type { ServerConfig } from '../../config.js'
import type { TicketIdentity } from '../../net/wsTicket.js'
import type { ModRuntime } from '../../mods/runtime.js'
++import type { ScriptModHost } from '../../mods/script/host.js'
import type { ServerMetrics } from '../../observability/metrics.js'
import type { GameEventRecorder } from '../../platform/gameEvents.js'
import type { ProgressSummaryWriter } from '../../platform/progressSummary.js'
@@@ -64,6 +65,8 @@ export interface GameIntegrations {
events?: GameEventRecorder
/** Installed mods (content packs), reconciled every tick. */
mods?: ModRuntime
++ /** games-quickjs@1 script mods; present only where the place enables them (GAMES_SCRIPT_MODS). */
++ scripts?: ScriptModHost
/** The person's games.progress.summary user module on Network, written when a character leaves. */
progressSummary?: ProgressSummaryWriter
}
ddiff --git a/apps/server/src/game/systems/mods.ts b/apps/server/src/game/systems/mods.ts
iindex 3fe787c..1fc04d7 100644
---- a/apps/server/src/game/systems/mods.ts
++++ b/apps/server/src/game/systems/mods.ts
@@@ -1,11 +1,23 @@
import { terrainHeight } from '@openvibe/content'
import { qfromYaw, quat, vec3, type EntityId, type PlayerId } from '@openvibe/shared'
import type { ModHost } from '../../mods/runtime.js'
++import type { ScriptPlayer, ScriptWorld } from '../../mods/script/host.js'
++import type { PlayerSession } from '../playerSession.js'
import type { ServerContext } from './context.js'
import type { System } from './system.js'
++function scriptPlayer(s: PlayerSession): ScriptPlayer {
++ return {
++ id: s.playerId,
++ name: s.name,
++ pos: [s.move.pos.x, s.move.pos.y, s.move.pos.z],
++ yaw: s.yaw,
++ }
++}
++
/**
-- * The mod runtime's host: the tick that reconciles installs and the capability surface it is lent.
++ * The mod runtime's host: the tick that reconciles installs and the capability surface it is lent,
++ * and, where the place enables them, the script mods' tick, join/leave hooks and world.
*/
export class ModSystem implements System {
readonly name = 'mods'
@@@ -41,11 +53,64 @@ export class ModSystem implements System {
entityExists: (id) => this.ctx.world.entities.get(id as EntityId) !== undefined,
}
-- constructor(private readonly ctx: ServerContext) {}
++ /**
++ * What script mods read and change. Changes arrive as validated intents and
++ * go through the same inventory and broadcast paths the game itself uses.
++ */
++ readonly scriptWorld: ScriptWorld = {
++ players: () => [...this.ctx.sessions.values()].map(scriptPlayer),
++ player: (id) => {
++ const session = this.ctx.sessions.get(id)
++ return session ? scriptPlayer(session) : null
++ },
++ apply: (_modId, intent) => {
++ switch (intent.kind) {
++ case 'announce':
++ this.ctx.net.broadcastAll({ t: 'announce', text: intent.text })
++ return null
++ case 'giveItem': {
++ const session = this.ctx.sessions.get(intent.playerId)
++ if (!session) return 'no such player'
++ if (!this.ctx.world.content.item(intent.item)) return 'unknown item'
++ if (!session.inventory.canFit(intent.item, intent.count)) return 'inventory_full'
++ session.inventory.add(intent.item, intent.count)
++ session.dirty = true
++ this.ctx.net.sendInventory(session)
++ return null
++ }
++ }
++ },
++ }
++
++ constructor(private readonly ctx: ServerContext) {
++ ctx.integrations.scripts?.bind(this.scriptWorld)
++ }
-- tick(ctx: ServerContext, _dt: number): void {
++ tick(ctx: ServerContext, dt: number): void {
// Reconcile installs (a revoked or disabled mod's effects are
// retracted on the first tick after the change).
ctx.integrations.mods?.tick(ctx.clock.tick, this.host)
++ const scripts = ctx.integrations.scripts
++ if (scripts) {
++ scripts.tick(ctx.clock.tick, dt)
++ let running = 0
++ let failures = 0
++ const status = scripts.status()
++ for (const s of status) {
++ if (s.state === 'running') running++
++ failures += s.failures.cpu + s.failures.memory + s.failures.stack + s.failures.error
++ }
++ ctx.metrics.scriptModsRunning = running
++ ctx.metrics.scriptModsDisabled = status.length - running
++ ctx.metrics.scriptModFailures = failures
++ }
++ }
++
++ onJoin(session: PlayerSession): void {
++ this.ctx.integrations.scripts?.playerJoined(scriptPlayer(session))
++ }
++
++ onLeave(session: PlayerSession): void {
++ this.ctx.integrations.scripts?.playerLeft(session.playerId)
}
}
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex e25339f..b3382de 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -34,6 +34,7 @@ import { createNetworkModGrants } from './mods/networkGrants.js'
import { createGamesActorLimits } from './net/actorLimits.js'
import { handleModsRequest } from './mods/routes.js'
import { ModRuntime } from './mods/runtime.js'
++import { ScriptModHost } from './mods/script/host.js'
import { isGuestToken } from './platform/accounts.js'
import { ProgressSummaryWriter } from './platform/progressSummary.js'
import {
@@@ -201,13 +202,25 @@ async function main(): Promise<void> {
const progressSummary = platform
? new ProgressSummaryWriter(platform.client, platformLog.child({ system: 'progress-summary' }))
: null
++ // games-quickjs@1 script mods run only where the place enables them; the engine (WASM) is not
++ // even loaded otherwise. They stay in the def set, so the handshake digest is the same either way.
++ const scriptLog = log.child({ system: 'script-mods' })
++ let scripts: ScriptModHost | undefined
++ if (config.scriptMods && content.allMods().length > 0) {
++ const { createQuickJsSandboxFactory } = await import('./mods/script/quickjs.js')
++ scripts = new ScriptModHost(content.allMods(), createQuickJsSandboxFactory(), scriptLog)
++ } else if (content.allMods().length > 0) {
++ scriptLog.info('script mods off for this place', { mods: content.allMods().length })
++ }
const game = new GameServer(config, world, store, metrics, log.child({ system: 'game' }), {
...(recorder ? { events: recorder } : {}),
mods: modRuntime,
++ ...(scripts ? { scripts } : {}),
...(progressSummary ? { progressSummary } : {}),
})
// Async boot: env, markets and the NPC population load before the tick loop starts.
await game.load()
++ await scripts?.start()
const accountData = platform
? createAccountData({
@@@ -373,6 +386,7 @@ async function main(): Promise<void> {
mods: {
installed: mods.list().length,
active: mods.list().filter((m) => mods.isActive(m.mod.id)).length,
++ scripts: { enabled: scripts !== undefined, mods: scripts?.status() ?? [] },
},
}),
)
@@@ -514,6 +528,7 @@ async function main(): Promise<void> {
// (that checkpoint plus every disconnect save queued before it) drain.
game.shutdown()
const drained = await game.drain()
++ scripts?.dispose()
const cut = await httpDone
await within(2000, mirrorDone)
await within(1000, progressSummary?.settle())
ddiff --git a/apps/server/src/mods/script/host.test.ts b/apps/server/src/mods/script/host.test.ts
nnew file mode 100644
iindex 0000000..d455cfe
---- /dev/null
++++ b/apps/server/src/mods/script/host.test.ts
@@@ -0,0 +1,236 @@
++import { describe, expect, it } from 'vitest'
++import type { ScriptMod } from '@openvibe/content'
++import type { Logger } from '@openvibe/shared'
++import {
++ ERROR_STRIKES,
++ parseIntent,
++ ScriptModHost,
++ type ScriptIntent,
++ type ScriptWorld,
++} from './host.js'
++import type { Json, ModSandboxFactory, SandboxHostFunctions, SandboxOutcome } from './sandbox.js'
++
++/** A fake engine: each mod's "script" is a host-side function driving the lent `game` functions. */
++type FakeScript = (
++ game: SandboxHostFunctions,
++ hook: string,
++ payload: Json,
++ cpuMs: number,
++ clock: { t: number },
++) => SandboxOutcome
++
++function fakeFactory(scripts: Record<string, FakeScript>, clock: { t: number }) {
++ const disposed: string[] = []
++ const factory: ModSandboxFactory = {
++ engine: 'fake',
++ async create(spec) {
++ const script = scripts[spec.id]
++ if (!script) throw new Error(`no script ${spec.id}`)
++ return {
++ ok: true,
++ sandbox: {
++ call: (hook, payload, cpuMs) => script(spec.host, hook, payload, cpuMs, clock),
++ dispose: () => void disposed.push(spec.id),
++ },
++ }
++ },
++ }
++ return { factory, disposed }
++}
++
++function mod(id: string, budgets?: ScriptMod['budgets']): ScriptMod {
++ return {
++ format: 'games-quickjs@1',
++ id,
++ version: '1.0.0',
++ entry: `${id}.js`,
++ source: '/* fake */',
++ hooks: ['onTick', 'onPlayerJoin'],
++ ...(budgets ? { budgets } : {}),
++ }
++}
++
++function recordingWorld(): ScriptWorld & { applied: [string, ScriptIntent][] } {
++ const applied: [string, ScriptIntent][] = []
++ return {
++ applied,
++ players: () => [{ id: 'p1', name: 'Ana', pos: [0, 1, 0], yaw: 0 }],
++ player: (id) => (id === 'p1' ? { id: 'p1', name: 'Ana', pos: [0, 1, 0], yaw: 0 } : null),
++ apply: (modId, intent) => {
++ applied.push([modId, intent])
++ return null
++ },
++ }
++}
++
++const logs: [string, string, unknown][] = []
++const log: Logger = {
++ debug: (m, f) => void logs.push(['debug', m, f]),
++ info: (m, f) => void logs.push(['info', m, f]),
++ warn: (m, f) => void logs.push(['warn', m, f]),
++ error: (m, f) => void logs.push(['error', m, f]),
++ child: () => log,
++}
++
++async function boot(scripts: Record<string, FakeScript>, mods: ScriptMod[]) {
++ const clock = { t: 0 }
++ const { factory, disposed } = fakeFactory(scripts, clock)
++ const host = new ScriptModHost(mods, factory, log, () => clock.t)
++ const world = recordingWorld()
++ host.bind(world)
++ await host.start()
++ return { host, world, clock, disposed }
++}
++
++describe('ScriptModHost', () => {
++ it('applies intents only after the call completes; a failed call changes nothing', async () => {
++ const { host, world } = await boot(
++ {
++ good: (game, hook) => {
++ if (hook === 'onTick') game.emit!({ kind: 'announce', text: 'tick' })
++ return { ok: true }
++ },
++ half: (game) => {
++ game.emit!({ kind: 'giveItem', playerId: 'p1', item: 'scrap', count: 1 })
++ return { ok: false, reason: 'error', message: 'boom after emitting' }
++ },
++ },
++ [mod('good'), mod('half')],
++ )
++ host.tick(1, 1 / 30)
++ expect(world.applied).toEqual([['good', { kind: 'announce', text: 'tick' }]])
++ expect(host.status().find((s) => s.id === 'half')).toMatchObject({
++ state: 'running',
++ failures: { error: 1 },
++ intentsApplied: 0,
++ })
++ })
++
++ it('disables a mod over its cpuMs for this instance and keeps ticking the others', async () => {
++ const seen: number[] = []
++ const { host, world, disposed } = await boot(
++ {
++ hog: (_game, _hook, _payload, cpuMs, clock) => {
++ clock.t += cpuMs
++ return { ok: false, reason: 'cpu', message: 'interrupted' }
++ },
++ fine: (game, _hook, _payload, cpuMs) => {
++ seen.push(cpuMs)
++ game.emit!({ kind: 'announce', text: 'still here' })
++ return { ok: true }
++ },
++ },
++ [mod('hog', { cpuMs: 3 }), mod('fine')],
++ )
++ host.tick(1, 1 / 30)
++ host.tick(2, 1 / 30)
++ const hog = host.status().find((s) => s.id === 'hog')!
++ expect(hog).toMatchObject({ state: 'disabled', reason: 'cpu', calls: 1, failures: { cpu: 1 } })
++ expect(disposed).toEqual(['hog'])
++ expect(seen).toEqual([2, 2]) // the default budget, offered whole each tick
++ expect(world.applied).toHaveLength(2)
++ expect(logs.some(([l, m]) => l === 'warn' && m === 'script mod disabled')).toBe(true)
++ })
++
++ it('shares cpuMs across every call in a tick and disables a mod that spent it', async () => {
++ const offered: number[] = []
++ const { host } = await boot(
++ {
++ busy: (_g, _h, _p, cpuMs, clock) => {
++ offered.push(cpuMs)
++ clock.t += 1.5
++ return { ok: true }
++ },
++ },
++ [mod('busy', { cpuMs: 3 })],
++ )
++ host.tick(1, 1 / 30)
++ host.playerJoined({ id: 'p1', name: 'Ana', pos: [0, 1, 0], yaw: 0 })
++ expect(offered).toEqual([3, 1.5])
++ host.playerJoined({ id: 'p2', name: 'Bo', pos: [0, 1, 0], yaw: 0 })
++ expect(host.status()[0]).toMatchObject({ state: 'disabled', reason: 'cpu' })
++ })
++
++ it('a throwing mod never throws into the tick, and is disabled after ERROR_STRIKES in a row', async () => {
++ const { host } = await boot(
++ {
++ thrower: () => {
++ throw new Error('engine bug')
++ },
++ },
++ [mod('thrower')],
++ )
++ for (let i = 1; i < ERROR_STRIKES; i++) expect(() => host.tick(i, 1 / 30)).not.toThrow()
++ expect(host.status()[0]).toMatchObject({
++ state: 'running',
++ failures: { error: ERROR_STRIKES - 1 },
++ })
++ host.tick(ERROR_STRIKES, 1 / 30)
++ expect(host.status()[0]).toMatchObject({ state: 'disabled', reason: 'error' })
++ })
++
++ it('a mod whose sandbox cannot be created is disabled and the rest load', async () => {
++ const { host } = await boot({ ok: () => ({ ok: true }) }, [mod('missing'), mod('ok')])
++ expect(host.status().map((s) => [s.id, s.state])).toEqual([
++ ['missing', 'disabled'],
++ ['ok', 'running'],
++ ])
++ })
++
++ it('refuses bad intents inside the call and caps intents per call', async () => {
++ const errors: string[] = []
++ const { host, world } = await boot(
++ {
++ spam: (game) => {
++ for (const bad of [{ kind: 'teleport' }, { kind: 'announce', text: '' }, 'x']) {
++ try {
++ game.emit!(bad)
++ } catch (err) {
++ errors.push((err as Error).message)
++ }
++ }
++ try {
++ for (let i = 0; i < 100; i++) game.emit!({ kind: 'announce', text: `n${i}` })
++ } catch (err) {
++ errors.push((err as Error).message)
++ }
++ return { ok: true }
++ },
++ },
++ [mod('spam')],
++ )
++ host.tick(1, 1 / 30)
++ expect(errors).toHaveLength(4)
++ expect(world.applied).toHaveLength(16)
++ })
++
++ it('game.emit outside a hook throws', async () => {
++ let captured: SandboxHostFunctions | null = null
++ const { host } = await boot(
++ {
++ keep: (game) => {
++ captured = game
++ return { ok: true }
++ },
++ },
++ [mod('keep')],
++ )
++ host.tick(1, 1 / 30)
++ expect(() => captured!.emit!({ kind: 'announce', text: 'late' })).toThrow(/inside a hook/)
++ })
++})
++
++describe('parseIntent', () => {
++ it('rebuilds the intent from known fields only', () => {
++ const raw = JSON.parse(
++ '{"kind":"giveItem","playerId":"p1","item":"scrap","count":2,"admin":true,"__proto__":{"polluted":1}}',
++ ) as Json
++ const intent = parseIntent(raw)
++ expect(intent).toEqual({ kind: 'giveItem', playerId: 'p1', item: 'scrap', count: 2 })
++ expect(Object.getPrototypeOf(intent)).toBe(Object.prototype)
++ expect(({} as Record<string, unknown>).polluted).toBeUndefined()
++ expect(parseIntent({ kind: 'giveItem', playerId: 'p1', item: 'scrap', count: 1000 })).toMatch(
++ /count/,
++ )
++ })
++})
ddiff --git a/apps/server/src/mods/script/host.ts b/apps/server/src/mods/script/host.ts
nnew file mode 100644
iindex 0000000..ce1e2e5
---- /dev/null
++++ b/apps/server/src/mods/script/host.ts
@@@ -0,0 +1,351 @@
++/**
++ * The script mod host: runs the `games-quickjs@1` mods of the def set, one
++ * sandbox each, from the tick and the join/leave hooks.
++ *
++ * A mod reads the world and asks for changes; it never makes them. What it
++ * can reach is the frozen global `game` (see docs/mods.md):
++ *
++ * game.players() every player in the world: { id, name, pos, yaw }
++ * game.player(id) one player, or null
++ * game.emit(intent) queue an intent (validated on the spot, throws if bad)
++ * game.log(...values) a line in the server log (a few per tick)
++ *
++ * Intents queued during a call are applied after the call returns, through
++ * `ScriptWorld.apply` (the gameplay modules), and only if the call completed:
++ * a call stopped for its budget or by a throw changes nothing.
++ *
++ * Budgets: `cpuMs` is per tick (every call the mod gets in one tick shares it)
++ * and caps each call; `memoryMb` and `stackKb` bound the mod's engine. A
++ * budget breach stops the call, disables the mod for this instance (its
++ * sandbox is freed), logs it and counts it; so does an engine failure, and
++ * ERROR_STRIKES throws in a row. The tick goes on either way; nothing a mod does reaches the
++ * caller as an exception.
++ */
++import { performance } from 'node:perf_hooks'
++import { SCRIPT_MOD_BUDGETS, type ScriptMod, type ScriptModHook } from '@openvibe/content'
++import type { Logger } from '@openvibe/shared'
++import type {
++ Json,
++ ModSandbox,
++ ModSandboxFactory,
++ SandboxFailureReason,
++ SandboxHostFunctions,
++} from './sandbox.js'
++
++/** The load (top-level code) may use this many ticks' worth of cpuMs. */
++export const LOAD_CPU_FACTOR = 25
++/** Throws in a row that disable a mod. */
++export const ERROR_STRIKES = 5
++export const MAX_INTENTS_PER_CALL = 16
++export const MAX_LOGS_PER_TICK = 4
++
++export interface ScriptPlayer {
++ id: string
++ name: string
++ pos: [number, number, number]
++ yaw: number
++}
++
++/** Everything a mod may ask for. Each is applied by the gameplay modules, never by the mod. */
++export type ScriptIntent =
++ | { kind: 'announce'; text: string }
++ | { kind: 'giveItem'; playerId: string; item: string; count: number }
++
++/** What the game lends the host: reads, and one door for changes. */
++export interface ScriptWorld {
++ players(): ScriptPlayer[]
++ player(id: string): ScriptPlayer | null
++ /** Applies a validated intent; returns why it was refused, or null. */
++ apply(modId: string, intent: ScriptIntent): string | null
++}
++
++export interface ScriptModBudget {
++ cpuMs: number
++ memoryMb: number
++ stackKb: number
++}
++
++export type ScriptModState = 'running' | 'disabled'
++
++export interface ScriptModStatus {
++ id: string
++ version: string
++ hooks: ScriptModHook[]
++ budget: ScriptModBudget
++ state: ScriptModState
++ /** Why it was disabled (a budget, or `error` after ERROR_STRIKES throws). */
++ reason: SandboxFailureReason | null
++ calls: number
++ failures: Record<SandboxFailureReason, number>
++ intentsApplied: number
++ intentsRejected: number
++}
++
++/** The manifest's requested budgets, with defaults for what it left out. */
++export function resolveBudget(mod: ScriptMod): ScriptModBudget {
++ return {
++ cpuMs: mod.budgets?.cpuMs ?? SCRIPT_MOD_BUDGETS.cpuMs.default,
++ memoryMb: mod.budgets?.memoryMb ?? SCRIPT_MOD_BUDGETS.memoryMb.default,
++ stackKb: mod.budgets?.stackKb ?? SCRIPT_MOD_BUDGETS.stackKb.default,
++ }
++}
++
++const isObject = (v: Json): v is { [key: string]: Json } =>
++ typeof v === 'object' && v !== null && !Array.isArray(v)
++const shortString = (v: Json | undefined, max: number): v is string =>
++ typeof v === 'string' && v.length > 0 && v.length <= max
++
++/**
++ * An intent from script JSON, rebuilt field by field: nothing the script put
++ * on the object besides the known fields (a `__proto__` key included) survives.
++ */
++export function parseIntent(raw: Json): ScriptIntent | string {
++ if (!isObject(raw)) return 'an intent must be an object'
++ switch (raw.kind) {
++ case 'announce': {
++ const text = typeof raw.text === 'string' ? raw.text.trim() : ''
++ if (!shortString(text, 200)) return 'announce: text must be 1-200 characters'
++ return { kind: 'announce', text }
++ }
++ case 'giveItem': {
++ const { playerId, item, count } = raw
++ if (!shortString(playerId, 64)) return 'giveItem: playerId must be a string'
++ if (!shortString(item, 64)) return 'giveItem: item must be a string'
++ if (typeof count !== 'number' || !Number.isInteger(count) || count < 1 || count > 100)
++ return 'giveItem: count must be an integer 1-100'
++ return { kind: 'giveItem', playerId, item, count }
++ }
++ default:
++ return `unknown intent kind ${JSON.stringify(raw.kind ?? null)}`
++ }
++}
++
++interface ModSlot {
++ mod: ScriptMod
++ budget: ScriptModBudget
++ hooks: Set<ScriptModHook>
++ sandbox: ModSandbox | null
++ state: ScriptModState
++ reason: SandboxFailureReason | null
++ /** CPU spent since the current tick began. */
++ spentMs: number
++ logsThisTick: number
++ strikes: number
++ calls: number
++ failures: Record<SandboxFailureReason, number>
++ intentsApplied: number
++ intentsRejected: number
++}
++
++export class ScriptModHost {
++ private readonly slots: ModSlot[]
++ private world: ScriptWorld | null = null
++ /** The call in progress: whose intents `game.emit` queues, and where. */
++ private current: { slot: ModSlot; intents: ScriptIntent[] } | null = null
++
++ constructor(
++ mods: readonly ScriptMod[],
++ private readonly factory: ModSandboxFactory,
++ private readonly log: Logger,
++ private readonly now: () => number = () => performance.now(),
++ ) {
++ this.slots = mods.map((mod) => ({
++ mod,
++ budget: resolveBudget(mod),
++ hooks: new Set(mod.hooks),
++ sandbox: null,
++ state: 'running',
++ reason: null,
++ spentMs: 0,
++ logsThisTick: 0,
++ strikes: 0,
++ calls: 0,
++ failures: { cpu: 0, memory: 0, stack: 0, error: 0 },
++ intentsApplied: 0,
++ intentsRejected: 0,
++ }))
++ }
++
++ /** The world the mods read and change; the game server binds it once. */
++ bind(world: ScriptWorld): void {
++ this.world = world
++ }
++
++ /** Creates every sandbox and evaluates each mod once. A mod that fails to load is disabled. */
++ async start(): Promise<void> {
++ for (const slot of this.slots) {
++ const { mod, budget } = slot
++ let created: Awaited<ReturnType<ModSandboxFactory['create']>>
++ try {
++ created = await this.factory.create({
++ id: mod.id,
++ filename: mod.entry,
++ source: mod.source,
++ limits: { memoryBytes: budget.memoryMb * 1024 * 1024, stackBytes: budget.stackKb * 1024 },
++ loadCpuMs: budget.cpuMs * LOAD_CPU_FACTOR,
++ host: this.hostFunctions(slot),
++ })
++ } catch (err) {
++ created = {
++ ok: false,
++ reason: 'error',
++ message: err instanceof Error ? err.message : String(err),
++ }
++ }
++ if (!created.ok) {
++ slot.failures[created.reason]++
++ this.disable(slot, created.reason, `load: ${created.message}`)
++ continue
++ }
++ slot.sandbox = created.sandbox
++ this.log.info('script mod loaded', {
++ mod: mod.id,
++ version: mod.version,
++ engine: this.factory.engine,
++ hooks: mod.hooks.join(','),
++ cpuMs: budget.cpuMs,
++ memoryMb: budget.memoryMb,
++ stackKb: budget.stackKb,
++ })
++ }
++ }
++
++ tick(tick: number, dtSeconds: number): void {
++ for (const slot of this.slots) {
++ slot.spentMs = 0
++ slot.logsThisTick = 0
++ }
++ for (const slot of this.slots) this.run(slot, 'onTick', { tick, dt: dtSeconds })
++ }
++
++ playerJoined(player: ScriptPlayer): void {
++ for (const slot of this.slots) this.run(slot, 'onPlayerJoin', { player: { ...player } })
++ }
++
++ playerLeft(playerId: string): void {
++ for (const slot of this.slots) this.run(slot, 'onPlayerLeave', { playerId })
++ }
++
++ status(): ScriptModStatus[] {
++ return this.slots.map((s) => ({
++ id: s.mod.id,
++ version: s.mod.version,
++ hooks: [...s.mod.hooks],
++ budget: { ...s.budget },
++ state: s.state,
++ reason: s.reason,
++ calls: s.calls,
++ failures: { ...s.failures },
++ intentsApplied: s.intentsApplied,
++ intentsRejected: s.intentsRejected,
++ }))
++ }
++
++ /** Frees every sandbox (shutdown). */
++ dispose(): void {
++ for (const slot of this.slots) {
++ slot.sandbox?.dispose()
++ slot.sandbox = null
++ }
++ }
++
++ private run(slot: ModSlot, hook: ScriptModHook, payload: Json): void {
++ const sandbox = slot.sandbox
++ if (slot.state !== 'running' || !sandbox || !slot.hooks.has(hook)) return
++ const remaining = slot.budget.cpuMs - slot.spentMs
++ if (remaining <= 0) {
++ slot.failures.cpu++
++ this.disable(slot, 'cpu', `${hook}: this tick's ${slot.budget.cpuMs} cpuMs are spent`)
++ return
++ }
++ const intents: ScriptIntent[] = []
++ this.current = { slot, intents }
++ const started = this.now()
++ let outcome: ReturnType<ModSandbox['call']>
++ try {
++ outcome = sandbox.call(hook, payload, remaining)
++ } catch (err) {
++ outcome = {
++ ok: false,
++ reason: 'error',
++ message: err instanceof Error ? err.message : String(err),
++ }
++ } finally {
++ this.current = null
++ }
++ slot.spentMs += this.now() - started
++ slot.calls++
++ if (!outcome.ok) {
++ slot.failures[outcome.reason]++
++ if (outcome.reason !== 'error' || outcome.fatal) {
++ this.disable(slot, outcome.reason, `${hook}: ${outcome.message}`)
++ return
++ }
++ slot.strikes++
++ if (slot.strikes >= ERROR_STRIKES) {
++ this.disable(
++ slot,
++ 'error',
++ `${hook}: ${ERROR_STRIKES} throws in a row, last: ${outcome.message}`,
++ )
++ } else if (slot.strikes === 1) {
++ this.log.warn('script mod threw', { mod: slot.mod.id, hook, error: outcome.message })
++ }
++ return
++ }
++ slot.strikes = 0
++ for (const intent of intents) {
++ const refused = this.world ? this.world.apply(slot.mod.id, intent) : 'no world bound'
++ if (refused === null) slot.intentsApplied++
++ else {
++ slot.intentsRejected++
++ this.log.debug('script mod intent refused', {
++ mod: slot.mod.id,
++ kind: intent.kind,
++ refused,
++ })
++ }
++ }
++ }
++
++ private disable(slot: ModSlot, reason: SandboxFailureReason, message: string): void {
++ slot.state = 'disabled'
++ slot.reason = reason
++ slot.sandbox?.dispose()
++ slot.sandbox = null
++ this.log.warn('script mod disabled', {
++ mod: slot.mod.id,
++ reason,
++ message: message.slice(0, 500),
++ })
++ }
++
++ /** The functions behind `game`; each knows its mod, so no mod can act as another. */
++ private hostFunctions(slot: ModSlot): SandboxHostFunctions {
++ return {
++ players: () => (this.world?.players() ?? []) as unknown as Json,
++ player: (id) => {
++ if (typeof id !== 'string') throw new Error('game.player(id): id must be a string')
++ return (this.world?.player(id) ?? null) as unknown as Json
++ },
++ emit: (raw) => {
++ const call = this.current
++ if (!call || call.slot !== slot)
++ throw new Error('game.emit is only available inside a hook')
++ if (call.intents.length >= MAX_INTENTS_PER_CALL)
++ throw new Error(`at most ${MAX_INTENTS_PER_CALL} intents per call`)
++ const intent = parseIntent(raw ?? null)
++ if (typeof intent === 'string') throw new Error(intent)
++ call.intents.push(intent)
++ return undefined
++ },
++ log: (...values) => {
++ if (slot.logsThisTick >= MAX_LOGS_PER_TICK) return undefined
++ slot.logsThisTick++
++ const text = values.map((v) => (typeof v === 'string' ? v : JSON.stringify(v))).join(' ')
++ this.log.info('script mod log', { mod: slot.mod.id, text: text.slice(0, 500) })
++ return undefined
++ },
++ }
++ }
++}
ddiff --git a/apps/server/src/mods/script/quickjs.test.ts b/apps/server/src/mods/script/quickjs.test.ts
nnew file mode 100644
iindex 0000000..83cc2f3
---- /dev/null
++++ b/apps/server/src/mods/script/quickjs.test.ts
@@@ -0,0 +1,297 @@
++/**
++ * The QuickJS sandbox against real scripts: escapes, budgets, and the host
++ * driving it end to end. Timings use generous slack (software CI) but stay far
++ * below what an unbounded script would take.
++ */
++import { performance } from 'node:perf_hooks'
++import { describe, expect, it } from 'vitest'
++import type { ScriptMod } from '@openvibe/content'
++import type { Logger } from '@openvibe/shared'
++import { ScriptModHost, type ScriptIntent, type ScriptPlayer, type ScriptWorld } from './host.js'
++import { createQuickJsSandboxFactory } from './quickjs.js'
++import type { Json, ModSandbox, SandboxHostFunctions, SandboxSpec } from './sandbox.js'
++
++const factory = createQuickJsSandboxFactory()
++const MB = 1024 * 1024
++
++async function sandbox(
++ source: string,
++ host: SandboxHostFunctions = {},
++ limits: Partial<SandboxSpec['limits']> = {},
++): Promise<ModSandbox> {
++ const made = await factory.create({
++ id: 'test',
++ filename: 'test.js',
++ source,
++ limits: { memoryBytes: 8 * MB, stackBytes: 128 * 1024, ...limits },
++ loadCpuMs: 200,
++ host,
++ })
++ if (!made.ok) throw new Error(`load failed: ${made.reason} ${made.message}`)
++ return made.sandbox
++}
++
++/** A sandbox whose `report(v)` collects values, for scripts that answer questions. */
++async function asking(source: string): Promise<{ box: ModSandbox; answers: Json[] }> {
++ const answers: Json[] = []
++ const box = await sandbox(source, { report: (v) => void answers.push(v ?? null) })
++ return { box, answers }
++}
++
++const quiet: Logger = {
++ debug: () => {},
++ info: () => {},
++ warn: () => {},
++ error: () => {},
++ child: () => quiet,
++}
++
++describe('QuickJS sandbox: escapes', () => {
++ it('exposes no require, process, timers, module system or host objects', async () => {
++ const { box, answers } = await asking(`
++ function onTick() {
++ const names = ['require', 'process', 'module', 'exports', '__dirname', 'global', 'Buffer',
++ 'setTimeout', 'setInterval', 'setImmediate', 'fetch', 'XMLHttpRequest', 'WebSocket',
++ 'std', 'os', 'scriptArgs', 'print', 'Deno', 'Bun', 'WebAssembly'];
++ game.report(names.filter((n) => typeof globalThis[n] !== 'undefined'));
++ // The Function constructor reaches this context's global, never the host's.
++ game.report(Function('return typeof process')());
++ game.report(game.report.constructor('return typeof require')());
++ // Everything on the global that is not a language intrinsic.
++ game.report(Object.getOwnPropertyNames(globalThis).filter((n) => /^[a-z_$]/.test(n) && ![
++ 'globalThis', 'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'decodeURI', 'decodeURIComponent',
++ 'encodeURI', 'encodeURIComponent', 'escape', 'unescape', 'eval', 'undefined', 'onTick',
++ 'game'].includes(n)));
++ // The lent object is frozen and cannot be replaced.
++ try { game.report = null } catch (e) {}
++ try { globalThis.game = {} } catch (e) {}
++ game.report([Object.isFrozen(game), typeof game.report]);
++ }`)
++ expect(box.call('onTick', null, 100)).toEqual({ ok: true })
++ expect(answers).toEqual([[], 'undefined', 'undefined', [], [true, 'function']])
++ box.dispose()
++ })
++
++ it('cannot pollute host prototypes or reach host objects through the bridge', async () => {
++ const seen: Json[] = []
++ const box = await sandbox(
++ `function onTick() {
++ Object.prototype.polluted = 'yes';
++ Array.prototype.push = function () { return 'hijacked' };
++ JSON.parse = function () { return { admin: true } };
++ const got = game.echo({ __proto__: { admin: true }, constructor: { prototype: { admin: true } } });
++ game.echo(JSON.stringify(Object.keys(got)));
++ }`,
++ { echo: (v) => (seen.push(v ?? null), v ?? null) },
++ )
++ expect(box.call('onTick', null, 100)).toEqual({ ok: true })
++ const plain: Record<string, unknown> = {}
++ expect(plain.polluted).toBeUndefined()
++ expect(plain.admin).toBeUndefined()
++ const host: number[] = []
++ expect(host.push(1)).toBe(1)
++ expect(JSON.parse('{}')).toEqual({})
++ // Only data arrived, as fresh host objects with the host's own prototype.
++ expect(Object.getPrototypeOf(seen[0])).toBe(Object.prototype)
++ expect((seen[0] as Record<string, unknown>).admin).toBeUndefined()
++ box.dispose()
++ })
++
++ it("cannot read or change another mod's state", async () => {
++ const a = await asking(`
++ globalThis.secret = 'a-only';
++ Object.prototype.leak = 'from a';
++ function onTick() { game.report([typeof secret, ({}).leak ?? null]) }`)
++ const b = await asking(`
++ function onTick() {
++ game.report([typeof secret, ({}).leak ?? null, typeof globalThis.onTick]);
++ }`)
++ expect(b.box.call('onTick', null, 100)).toEqual({ ok: true })
++ expect(a.box.call('onTick', null, 100)).toEqual({ ok: true })
++ expect(b.answers).toEqual([['undefined', null, 'function']])
++ expect(a.answers).toEqual([['string', 'from a']])
++ a.box.dispose()
++ b.box.dispose()
++ })
++})
++
++describe('QuickJS sandbox: budgets', () => {
++ it('stops an infinite loop within its cpuMs', async () => {
++ const box = await sandbox('function onTick() { for (;;) {} }')
++ const started = performance.now()
++ const outcome = box.call('onTick', null, 5)
++ const took = performance.now() - started
++ expect(outcome).toMatchObject({ ok: false, reason: 'cpu' })
++ expect(took).toBeLessThan(5 + 100)
++ box.dispose()
++ })
++
++ it('stops a promise-job loop, even one that swallows the interrupt, within the same cpuMs', async () => {
++ for (const source of [
++ 'function onTick() { const spin = () => Promise.resolve().then(spin); spin() }',
++ 'function onTick() { const spin = () => Promise.resolve().then(spin, spin); spin() }',
++ 'async function onTick() { for (;;) {} }',
++ ]) {
++ const box = await sandbox(source)
++ const started = performance.now()
++ expect(box.call('onTick', null, 5)).toMatchObject({ ok: false, reason: 'cpu' })
++ expect(performance.now() - started).toBeLessThan(5 + 100)
++ box.dispose()
++ }
++ })
++
++ it('stops an allocation bomb at memoryMb, even one that catches the error', async () => {
++ for (const source of [
++ 'function onTick() { const keep = []; for (;;) keep.push(new Array(1 << 16).fill(1.5)) }',
++ 'function onTick() { const keep = []; for (;;) keep.push({ n: keep.length }) }',
++ `function onTick() {
++ const keep = [];
++ try { for (;;) keep.push(new Float64Array(1 << 16)) } catch (e) { keep.length = 0 }
++ }`,
++ ]) {
++ const box = await sandbox(source, {}, { memoryBytes: 4 * MB })
++ expect(box.call('onTick', null, 2000)).toMatchObject({ ok: false, reason: 'memory' })
++ box.dispose()
++ }
++ })
++
++ it('refuses a single allocation larger than memoryMb', async () => {
++ const box = await sandbox(
++ 'function onTick() { new Float64Array(1 << 20) }',
++ {},
++ {
++ memoryBytes: 4 * MB,
++ },
++ )
++ expect(box.call('onTick', null, 100)).toMatchObject({ ok: false, reason: 'memory' })
++ box.dispose()
++ })
++
++ it("survives an engine path that exhausts the host's own stack, as a fatal stack breach", async () => {
++ const other = await asking('function onTick() { game.report("still here") }')
++ const box = await sandbox(
++ 'function onTick() { let a = []; for (let i = 0; i < 1e5; i++) a = [a]; JSON.stringify(a) }',
++ {},
++ { memoryBytes: 32 * MB },
++ )
++ expect(box.call('onTick', null, 2000)).toMatchObject({
++ ok: false,
++ reason: 'stack',
++ fatal: true,
++ })
++ expect(box.call('onTick', null, 100)).toMatchObject({ ok: false, fatal: true })
++ expect(() => box.dispose()).not.toThrow()
++ expect(other.box.call('onTick', null, 100)).toEqual({ ok: true })
++ expect(other.answers).toEqual(['still here'])
++ other.box.dispose()
++ })
++
++ it('stops runaway recursion at the stack limit', async () => {
++ const box = await sandbox('function onTick() { const f = (n) => f(n + 1) + 1; f(0) }')
++ expect(box.call('onTick', null, 2000)).toMatchObject({ ok: false, reason: 'stack' })
++ box.dispose()
++ })
++
++ it('reports a top-level infinite loop as a load failure', async () => {
++ const made = await factory.create({
++ id: 'boot-loop',
++ filename: 'boot-loop.js',
++ source: 'while (true) {}',
++ limits: { memoryBytes: 8 * MB, stackBytes: 128 * 1024 },
++ loadCpuMs: 10,
++ host: {},
++ })
++ expect(made).toMatchObject({ ok: false, reason: 'cpu' })
++ })
++})
++
++describe('ScriptModHost on QuickJS', () => {
++ const base = { format: 'games-quickjs@1', version: '1.0.0', hooks: ['onTick'] } as const
++ const scriptMod = (id: string, source: string, extra: Partial<ScriptMod> = {}): ScriptMod => ({
++ ...base,
++ hooks: [...base.hooks],
++ id,
++ entry: `${id}.js`,
++ source,
++ ...extra,
++ })
++
++ function world(): ScriptWorld & { applied: ScriptIntent[]; ana: ScriptPlayer } {
++ const ana: ScriptPlayer = { id: 'p1', name: 'Ana', pos: [1, 2, 3], yaw: 0 }
++ const applied: ScriptIntent[] = []
++ return {
++ ana,
++ applied,
++ players: () => [ana],
++ player: (id) => (id === ana.id ? ana : null),
++ apply: (_mod, intent) => {
++ applied.push(intent)
++ if (intent.kind === 'giveItem') ana.name = `${ana.name}+${intent.count}${intent.item}`
++ return null
++ },
++ }
++ }
++
++ it('a valid mod changes game state through intents only', async () => {
++ const w = world()
++ const host = new ScriptModHost(
++ [
++ scriptMod(
++ 'gifts',
++ `function onTick(e) {
++ const [p] = game.players();
++ p.name = 'renamed in the sandbox'; // a copy: changes nothing outside
++ p.pos[0] = 999;
++ game.emit({ kind: 'giveItem', playerId: p.id, item: 'scrap', count: 2 });
++ game.log('gave scrap on tick', e.tick);
++ }`,
++ ),
++ ],
++ factory,
++ quiet,
++ )
++ host.bind(w)
++ await host.start()
++ host.tick(7, 1 / 30)
++ expect(w.applied).toEqual([{ kind: 'giveItem', playerId: 'p1', item: 'scrap', count: 2 }])
++ expect(w.ana).toEqual({ id: 'p1', name: 'Ana+2scrap', pos: [1, 2, 3], yaw: 0 })
++ expect(host.status()[0]).toMatchObject({ state: 'running', intentsApplied: 1 })
++ host.dispose()
++ })
++
++ it('a throwing or runaway mod does not stop the tick or the other mods', async () => {
++ const w = world()
++ const host = new ScriptModHost(
++ [
++ scriptMod('thrower', 'function onTick() { throw new TypeError("nope") }'),
++ scriptMod('looper', 'function onTick() { for (;;) {} }', { budgets: { cpuMs: 3 } }),
++ scriptMod(
++ 'bomb',
++ 'function onTick() { const k = []; for (;;) k.push(new Array(65536).fill(0)) }',
++ {
++ budgets: { memoryMb: 4, cpuMs: 10 },
++ },
++ ),
++ scriptMod('steady', 'function onTick() { game.emit({ kind: "announce", text: "ok" }) }'),
++ ],
++ factory,
++ quiet,
++ )
++ host.bind(w)
++ await host.start()
++ expect(() => {
++ host.tick(1, 1 / 30)
++ host.tick(2, 1 / 30)
++ }).not.toThrow()
++ const byId = Object.fromEntries(host.status().map((s) => [s.id, s]))
++ expect(byId.thrower).toMatchObject({ state: 'running', failures: { error: 2 } })
++ expect(byId.looper).toMatchObject({ state: 'disabled', reason: 'cpu' })
++ expect(byId.bomb).toMatchObject({ state: 'disabled', reason: 'memory' })
++ expect(byId.steady).toMatchObject({ state: 'running', intentsApplied: 2 })
++ expect(w.applied).toEqual([
++ { kind: 'announce', text: 'ok' },
++ { kind: 'announce', text: 'ok' },
++ ])
++ host.dispose()
++ })
++})
ddiff --git a/apps/server/src/mods/script/quickjs.ts b/apps/server/src/mods/script/quickjs.ts
nnew file mode 100644
iindex 0000000..d4c0dc0
---- /dev/null
++++ b/apps/server/src/mods/script/quickjs.ts
@@@ -0,0 +1,327 @@
++/**
++ * The QuickJS implementation of the sandbox adapter (sandbox.ts), on
++ * quickjs-emscripten: QuickJS compiled to WASM, no native build.
++ *
++ * Every mod gets its own WASM module instance and linear memory, runtime and
++ * context, so a mod cannot see another's heap even through an engine bug, and
++ * an engine abort poisons only that mod's instance. A fresh context holds the
++ * language intrinsics only — QuickJS's std/os libraries are not part of the
++ * build — and the host adds exactly one thing, the frozen global `game`.
++ *
++ * Budgets:
++ * - cpuMs: the interrupt handler stops a call at its deadline (QuickJS polls
++ * it every few thousand operations). An interrupt a promise job or async
++ * hook swallows into a rejection is still a breach: the flag is ours.
++ * - memory: QuickJS's own malloc limit cannot count bytes under emscripten
++ * (no malloc_usable_size: it sees single allocations and a per-block
++ * overhead, not the total), so the bound is the WASM memory itself, capped
++ * at the engine's base heap plus `memoryBytes` (HeapCeiling). The malloc
++ * limit stays as a second line against any single allocation that large.
++ * - stack: the runtime's max stack size turns deep recursion into a catchable
++ * stack overflow. A few engine paths recurse in C without that check (a
++ * deeply nested JSON.stringify) and exhaust the host's own stack instead;
++ * that RangeError is caught here, reported as a stack breach, and the
++ * instance is treated as dead.
++ */
++import { performance } from 'node:perf_hooks'
++import {
++ newQuickJSWASMModule,
++ newVariant,
++ RELEASE_SYNC,
++ type QuickJSContext,
++ type QuickJSHandle,
++ type QuickJSRuntime,
++} from 'quickjs-emscripten'
++import type {
++ Json,
++ ModSandbox,
++ ModSandboxFactory,
++ SandboxFailure,
++ SandboxFailureReason,
++ SandboxHostFunctions,
++ SandboxOutcome,
++ SandboxSpec,
++} from './sandbox.js'
++
++/** Must match the exact version pinned in apps/server/package.json. */
++export const QUICKJS_ENGINE = '[email protected]'
++
++/** The linear memory this build declares it starts with; QuickJS's own state lives in it. */
++export const ENGINE_BASE_BYTES = 16 * 1024 * 1024
++const WASM_PAGE = 64 * 1024
++
++/** Longest JSON a host call may pass either way; past it the call throws inside the script. */
++const MAX_HOST_JSON = 64 * 1024
++/** Longest failure message kept from a script (it chooses what it throws). */
++const MAX_MESSAGE = 500
++
++/**
++ * Runs before the mod, with the native bridge as its only argument; the bridge
++ * never becomes a global. It builds the frozen `game` object (one wrapper per
++ * lent function, JSON in and out) and returns the hook dispatcher the host
++ * keeps. Nothing it does at call time looks up a global or a prototype method
++ * the mod could have replaced (`JSON` is captured, arguments are copied by index).
++ */
++const PRELUDE = `(function (bridge, namesJson) {
++ 'use strict';
++ const parse = JSON.parse, stringify = JSON.stringify, freeze = Object.freeze;
++ const game = {};
++ const names = parse(namesJson);
++ for (let i = 0; i < names.length; i++) {
++ const name = names[i];
++ game[name] = function () {
++ const args = [];
++ for (let k = 0; k < arguments.length; k++) args[k] = arguments[k];
++ const out = bridge(name, stringify(args));
++ return out === undefined ? undefined : parse(out);
++ };
++ freeze(game[name]);
++ }
++ freeze(game);
++ Object.defineProperty(globalThis, 'game', { value: game, writable: false, enumerable: false, configurable: false });
++ return function dispatch(hook, payload) {
++ const fn = globalThis[hook];
++ if (typeof fn === 'function') fn(parse(payload));
++ };
++})`
++
++/** The part of WebAssembly.Memory used here; the server's TS lib (ES2022, no DOM) does not declare it. */
++interface WasmMemory {
++ readonly buffer: ArrayBuffer
++ grow(deltaPages: number): number
++}
++const { Memory } = (
++ globalThis as unknown as {
++ WebAssembly: { Memory: new (d: { initial: number; maximum: number }) => WasmMemory }
++ }
++).WebAssembly
++
++/**
++ * A mod's linear memory, capped at ENGINE_BASE_BYTES + `memoryBytes`. Past the
++ * cap the engine's allocator is refused and the allocation fails inside the
++ * script ("out of memory"). `exceeded` stays true from the moment an
++ * allocation needed more than the cap — even if the script caught the error —
++ * because the memory never shrinks and can no longer grow.
++ */
++class HeapCeiling {
++ readonly memory: WasmMemory
++ private refusedAt = -1
++
++ constructor(memoryBytes: number) {
++ const base = ENGINE_BASE_BYTES / WASM_PAGE
++ const memory = new Memory({
++ initial: base,
++ maximum: base + Math.ceil(memoryBytes / WASM_PAGE),
++ })
++ // The allocator asks for a generous step first and smaller ones after a
++ // refusal; only a refusal not followed by a successful grow is a breach.
++ const grow = memory.grow.bind(memory)
++ memory.grow = (delta) => {
++ try {
++ return grow(delta)
++ } catch (err) {
++ this.refusedAt = memory.buffer.byteLength
++ throw err
++ }
++ }
++ this.memory = memory
++ }
++
++ get exceeded(): boolean {
++ return this.refusedAt === this.memory.buffer.byteLength
++ }
++}
++
++function failure(reason: SandboxFailureReason, message: string, fatal = false): SandboxFailure {
++ return { ok: false, reason, message: message.slice(0, MAX_MESSAGE), ...(fatal && { fatal }) }
++}
++
++/** A script exception that is not a breach of ours: the engine's own limits, or a throw. */
++function classify(message: string): SandboxFailure {
++ if (/out of memory/i.test(message)) return failure('memory', message)
++ if (/stack overflow/i.test(message)) return failure('stack', message)
++ return failure('error', message)
++}
++
++/** A thrown script value as one line of text (an Error's name, message and first stack frame). */
++function describe(ctx: QuickJSContext, handle: QuickJSHandle): string {
++ try {
++ const v: unknown = ctx.dump(handle)
++ if (v && typeof v === 'object' && 'message' in v) {
++ const e = v as { name?: unknown; message?: unknown; stack?: unknown }
++ const at = typeof e.stack === 'string' ? e.stack.trim().split('\n')[0] : ''
++ return `${String(e.name ?? 'Error')}: ${String(e.message)}${at ? ` (${at.trim()})` : ''}`
++ }
++ return String(v)
++ } catch {
++ return 'an exception the engine could not describe'
++ } finally {
++ handle.dispose()
++ }
++}
++
++class QuickJsSandbox implements ModSandbox {
++ private deadline = Infinity
++ private cpuMs = 0
++ /** Set by us, never by the script: the interrupt handler stops the call while it is set. */
++ private breach: 'cpu' | 'memory' | null = null
++ /** The engine threw into the host (an abort, the host's stack): nothing in it can be trusted. */
++ private poisoned = false
++ private disposed = false
++ private dispatch: QuickJSHandle | null = null
++
++ constructor(
++ private readonly runtime: QuickJSRuntime,
++ private readonly ctx: QuickJSContext,
++ private readonly heap: HeapCeiling,
++ ) {
++ runtime.setInterruptHandler(() => {
++ if (this.breach === null) {
++ if (heap.exceeded) this.breach = 'memory'
++ else if (performance.now() > this.deadline) this.breach = 'cpu'
++ }
++ return this.breach !== null
++ })
++ }
++
++ /** Installs `game`, then evaluates the mod once. */
++ load(spec: SandboxSpec): SandboxOutcome {
++ const ctx = this.ctx
++ return this.run(spec.loadCpuMs, () => {
++ const bridge = ctx.newFunction('bridge', (nameH, argsH) =>
++ bridgeCall(ctx, spec.host, nameH, argsH),
++ )
++ const names = ctx.newString(JSON.stringify(Object.keys(spec.host)))
++ const prelude = ctx.evalCode(PRELUDE, 'host-prelude.js')
++ if (prelude.error) {
++ bridge.dispose()
++ names.dispose()
++ return prelude.error
++ }
++ const made = ctx.callFunction(prelude.value, ctx.undefined, bridge, names)
++ prelude.value.dispose()
++ bridge.dispose()
++ names.dispose()
++ if (made.error) return made.error
++ this.dispatch = made.value
++ const evaluated = ctx.evalCode(spec.source, spec.filename)
++ if (evaluated.error) return evaluated.error
++ evaluated.value.dispose()
++ return null
++ })
++ }
++
++ call(hook: string, payload: Json, cpuMs: number): SandboxOutcome {
++ if (this.disposed || this.poisoned || !this.dispatch)
++ return failure('error', 'the sandbox is no longer usable', true)
++ const ctx = this.ctx
++ const dispatch = this.dispatch
++ return this.run(cpuMs, () => {
++ const hookH = ctx.newString(hook)
++ const payloadH = ctx.newString(JSON.stringify(payload))
++ const r = ctx.callFunction(dispatch, ctx.undefined, hookH, payloadH)
++ hookH.dispose()
++ payloadH.dispose()
++ if (r.error) return r.error
++ r.value.dispose()
++ return null
++ })
++ }
++
++ /**
++ * Runs `body` (which returns the script's exception handle, or null) and the
++ * promise jobs it queued, one at a time, all before one deadline.
++ */
++ private run(cpuMs: number, body: () => QuickJSHandle | null): SandboxOutcome {
++ this.breach = null
++ this.cpuMs = cpuMs
++ this.deadline = performance.now() + cpuMs
++ try {
++ let thrown = body()
++ while (!thrown && this.breach === null && this.runtime.hasPendingJob()) {
++ if (performance.now() > this.deadline) this.breach = 'cpu'
++ else {
++ const jobs = this.runtime.executePendingJobs(1)
++ if (jobs.error) thrown = jobs.error
++ }
++ }
++ if (this.breach === null && this.heap.exceeded) this.breach = 'memory'
++ const message = thrown ? describe(this.ctx, thrown) : ''
++ if (this.breach === 'cpu') return failure('cpu', `interrupted: over its ${this.cpuMs} cpuMs`)
++ if (this.breach === 'memory')
++ return failure('memory', `heap over its memory budget${message ? `: ${message}` : ''}`)
++ return thrown ? classify(message) : { ok: true }
++ } catch (err) {
++ this.poisoned = true
++ const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
++ if (err instanceof RangeError && /call stack/i.test(message))
++ return failure('stack', `the host's stack ran out: ${message}`, true)
++ return failure(this.breach ?? 'error', `the engine failed: ${message}`, true)
++ } finally {
++ this.deadline = Infinity
++ }
++ }
++
++ dispose(): void {
++ if (this.disposed) return
++ this.disposed = true
++ // A poisoned engine is only dropped: its teardown would trip the engine's
++ // own consistency checks. The WASM instance is this mod's alone.
++ if (this.poisoned) return
++ try {
++ this.dispatch?.dispose()
++ this.ctx.dispose()
++ this.runtime.dispose()
++ } catch {
++ // Same: a runtime stopped mid-allocation can fail its teardown checks.
++ }
++ }
++}
++
++/** The native side of `game.<name>(...)`: JSON in, JSON out, errors as script exceptions. */
++function bridgeCall(
++ ctx: QuickJSContext,
++ host: SandboxHostFunctions,
++ nameH: QuickJSHandle | undefined,
++ argsH: QuickJSHandle | undefined,
++): QuickJSHandle | { error: QuickJSHandle } {
++ try {
++ if (!nameH || !argsH) throw new Error('bad bridge call')
++ const name = ctx.getString(nameH)
++ const raw = ctx.getString(argsH)
++ if (raw.length > MAX_HOST_JSON) throw new Error(`arguments to game.${name} are too large`)
++ const fn = Object.prototype.hasOwnProperty.call(host, name) ? host[name] : undefined
++ if (!fn) throw new Error(`game.${name} is not a function`)
++ const args = JSON.parse(raw) as Json[]
++ const out = fn(...args)
++ if (out === undefined) return ctx.undefined
++ const json = JSON.stringify(out)
++ if (json.length > MAX_HOST_JSON) throw new Error(`game.${name} returned too much`)
++ return ctx.newString(json)
++ } catch (err) {
++ // Only the message crosses: never the host Error object or its stack.
++ return { error: ctx.newError(err instanceof Error ? err.message : String(err)) }
++ }
++}
++
++export function createQuickJsSandboxFactory(): ModSandboxFactory {
++ return {
++ engine: QUICKJS_ENGINE,
++ async create(spec) {
++ const heap = new HeapCeiling(spec.limits.memoryBytes)
++ const module = await newQuickJSWASMModule(
++ newVariant(RELEASE_SYNC, { wasmMemory: heap.memory }),
++ )
++ const runtime = module.newRuntime()
++ runtime.setMemoryLimit(spec.limits.memoryBytes)
++ runtime.setMaxStackSize(spec.limits.stackBytes)
++ const sandbox = new QuickJsSandbox(runtime, runtime.newContext(), heap)
++ const loaded = sandbox.load(spec)
++ if (!loaded.ok) {
++ sandbox.dispose()
++ return loaded
++ }
++ return { ok: true, sandbox }
++ },
++ }
++}
ddiff --git a/apps/server/src/mods/script/sandbox.ts b/apps/server/src/mods/script/sandbox.ts
nnew file mode 100644
iindex 0000000..2f299bd
---- /dev/null
++++ b/apps/server/src/mods/script/sandbox.ts
@@@ -0,0 +1,73 @@
++/**
++ * The script sandbox adapter: the whole contract between the mod host and a
++ * script engine. QuickJS (quickjs.ts) is the first implementation; OpenVibe.Run
++ * can implement the same interface later (ADR-036) and the host does not change.
++ *
++ * The contract, which every implementation must keep:
++ * - one isolated context per mod: no state, prototype or object identity is
++ * shared between mods or with the host;
++ * - only JSON crosses the boundary, in both directions: a host function gets
++ * JSON arguments and returns JSON, a hook gets a JSON payload. No host
++ * object, function or prototype is ever reachable from the script;
++ * - nothing but the lent functions: no require/import, fs, net, process,
++ * timers or clock beyond what the engine's language intrinsics provide;
++ * - the host functions appear in the script as the frozen global `game`;
++ * - limits are enforced by the engine, not by trusting the script: `cpuMs`
++ * per call (wall clock on the single-threaded tick), a heap of
++ * `memoryBytes` (beyond the engine's own fixed base), a stack of
++ * `stackBytes`; a breach the script catches or swallows is still a breach;
++ * - `call` and `dispose` never throw: every failure is a `SandboxFailure`.
++ */
++
++export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }
++
++/** Functions lent to a script, named as they appear on `game`. A throw becomes an Error inside the script. */
++export type SandboxHostFunctions = Readonly<Record<string, (...args: Json[]) => Json | undefined>>
++
++export interface SandboxLimits {
++ memoryBytes: number
++ stackBytes: number
++}
++
++/** Why a call (or the load) did not complete. Every reason but `error` is a budget breach. */
++export type SandboxFailureReason = 'cpu' | 'memory' | 'stack' | 'error'
++
++export interface SandboxFailure {
++ ok: false
++ reason: SandboxFailureReason
++ message: string
++ /** The sandbox cannot be used again (the engine itself failed); the host must dispose it. */
++ fatal?: boolean
++}
++
++export type SandboxOutcome = { ok: true } | SandboxFailure
++
++export interface ModSandbox {
++ /**
++ * Calls the script's global function `hook` with `payload`, letting it run
++ * for at most `cpuMs`; pending promise jobs it queued run inside the same
++ * budget. A missing hook is a success.
++ */
++ call(hook: string, payload: Json, cpuMs: number): SandboxOutcome
++ /** Frees the context and everything in it. Idempotent. */
++ dispose(): void
++}
++
++export interface SandboxSpec {
++ /** The mod id, for diagnostics only. */
++ id: string
++ /** File name shown in the script's stack traces. */
++ filename: string
++ source: string
++ limits: SandboxLimits
++ /** CPU budget for evaluating the source once (top-level code). */
++ loadCpuMs: number
++ host: SandboxHostFunctions
++}
++
++export interface ModSandboxFactory {
++ /** Engine name and exact version, for logs and /api/status. */
++ readonly engine: string
++ /** A fresh, isolated context with `host` installed and `source` evaluated once. */
++ create(spec: SandboxSpec): Promise<{ ok: true; sandbox: ModSandbox } | SandboxFailure>
++}
ddiff --git a/apps/server/src/observability/metrics.ts b/apps/server/src/observability/metrics.ts
iindex 0bed38e..02001b5 100644
---- a/apps/server/src/observability/metrics.ts
++++ b/apps/server/src/observability/metrics.ts
@@@ -43,6 +43,10 @@ export class ServerMetrics {
mapZones = 0
/** Cumulative map-layer body rebuilds; identical saves must not move it. */
mapRebuilds = 0
++ /** games-quickjs@1 script mods: running, disabled for this instance, and failed calls (all reasons). */
++ scriptModsRunning = 0
++ scriptModsDisabled = 0
++ scriptModFailures = 0
private emaAlpha = 0.05
@@@ -78,6 +82,9 @@ export class ServerMetrics {
mapTerrains: this.mapTerrains,
mapZones: this.mapZones,
mapRebuilds: this.mapRebuilds,
++ scriptModsRunning: this.scriptModsRunning,
++ scriptModsDisabled: this.scriptModsDisabled,
++ scriptModFailures: this.scriptModFailures,
memRssMb: round2(process.memoryUsage.rss() / 1024 / 1024),
}
}
ddiff --git a/docs/adr/0007-rewrite-the-moddable-base.md b/docs/adr/0007-rewrite-the-moddable-base.md
iindex 67c0224..468d983 100644
---- a/docs/adr/0007-rewrite-the-moddable-base.md
++++ b/docs/adr/0007-rewrite-the-moddable-base.md
@@@ -121,6 +121,7 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegac
- **M2.3 Scraplandia as a pack — default map done.** The base loads Scraplandia's blank-slate map through the built-in `games-content@2` pack. The released spawn, zone and empty seeded entity set are unchanged. Moving gameplay modules behind pack enablement remains future work.
- **M2.4 touch controls — done.** `TouchInputSource` (`apps/client/src/input/touchInputSource.ts`) produces the same `InputSample` stream as the keyboard: an analog left stick (radial dead zone 0.15, length clamped to 1), right-half look drag with InputTracker's pitch clamp, and on-screen jump, sprint (tap latches, press holds), crouch and prone buttons on the existing `Intents` bits — no protocol change. Use, fire and menu buttons emit the same `InputAction`s as E, the left mouse button and Tab. Gesture state is DOM-free and unit-tested; `touchControls.ts` is the pointer-event overlay. The client starts on touch for a coarse pointer, switches to whichever device was used last, and never pointer-locks for touch.
- **M3: scripted mods.** QuickJS runtime, capability API v2, budgets enforced, hot reload, escape tests.
++ - **M3.1 runtime, budgets, escape tests — done (docs/mods.md).** `games-quickjs@1` manifests ride in packs and in the def-set digest; `apps/server/src/mods/script` runs each mod in its own `quickjs-emscripten` WASM instance behind the small adapter in `sandbox.ts` (the one OpenVibe.Run implements later); the host API is `game.players/player/emit/log`, changes arrive only as validated intents applied after a completed call; `cpuMs`/`memoryMb`/`stackKb` are enforced by the interrupt handler, a per-mod capped `WebAssembly.Memory` (QuickJS's malloc limit cannot count bytes under emscripten) and the max stack, and a breach disables the mod for the instance. Off unless the place sets `GAMES_SCRIPT_MODS=1`. Open: capability API v2, seeded randomness, hot reload, installing script mods through the mods API.
- **M4: instances and scale.** Several instances per place, registry and leases, transfer, region interest, load tests.
- **M5: platform game services.** OpenVibe.Play with Games as first consumer; the second game on the same base.
- **M6: client floor.** Quality tiers, KTX2 and atlases, baked icons, bundle budget, editor E2E in CI.
ddiff --git a/docs/mods.md b/docs/mods.md
nnew file mode 100644
iindex 0000000..4767ae0
---- /dev/null
++++ b/docs/mods.md
@@@ -0,0 +1,153 @@
++# Mods
++
++Games has two mod kinds. Both travel in a content pack (`ContentPackV2`,
++`packages/content/src/pack.ts`) and both are part of the def set, so the
++`hello`/`welcome` digest covers them.
++
++- **`games-content@2`**: declarative data (definitions, a map, announcements,
++ props). Installable through the mods API with per-capability grants; see the
++ README's Mods section.
++- **`games-quickjs@1`**: server script mods, described here (ADR-0007 decision 6,
++ tier T1).
++
++## `games-quickjs@1`
++
++A pack carries script mods in `mods: [...]`. Each entry is a manifest and its
++source together (`packages/content/src/schema/scriptMod.ts`, strict, so an
++unknown field is refused):
++
++| field | rule |
++| --------- | ------------------------------------------------------------------------ |
++| `format` | `"games-quickjs@1"` |
++| `id` | `^[a-z0-9][a-z0-9_-]{0,39}$`, unique across all packs (never overridden) |
++| `version` | `major.minor.patch` |
++| `entry` | the script's file name (`*.js`), shown in stack traces |
++| `source` | the script, at most 64 Ki characters |
++| `hooks` | one or more of `onTick`, `onPlayerJoin`, `onPlayerLeave` |
++| `budgets` | optional `{ cpuMs, memoryMb, stackKb }`, each up to its ceiling |
++
++A pack may carry up to 8 mods. v1 mods are a single script: there is no module
++loader, so `import` and `require` do not exist.
++
++**Pinned by the digest.** Manifests and sources are merged into `ContentDefs.mods`
++and hashed by `contentDigest` with every other definition. A client whose copy of
++a mod differs by one byte (or that lacks it) is refused with `content_mismatch`
++before any player state is created. A def set without mods keeps the digest it
++had before mods existed.
++
++**Where mods come from.** Packs passed to `createContent` (the built-in packs in
++`packages/content/src/packs`). The mods API still refuses executable installs: a
++script mod ships with the code of the place, reviewed like code.
++
++**Off unless the place enables it.** `GAMES_SCRIPT_MODS=1` (or `true`) runs the
++def set's script mods on this instance. Without it the engine is never loaded
++and the server logs `script mods off for this place`; the mods stay in the def
++set either way, so the digest does not depend on the flag.
++
++### Writing a mod
++
++The script runs once at load (top-level code), then the host calls the global
++functions named by `hooks`:
++
++```js
++// greeter.js — hooks: ["onPlayerJoin"]
++function onPlayerJoin(e) {
++ game.emit({ kind: 'announce', text: `Welcome, ${e.player.name}` })
++}
++```
++
++| hook | payload |
++| --------------- | ------------------------------------ |
++| `onTick` | `{ tick, dt }` (dt in seconds) |
++| `onPlayerJoin` | `{ player: { id, name, pos, yaw } }` |
++| `onPlayerLeave` | `{ playerId }` |
++
++A declared hook that the script does not define is skipped.
++
++### Host API
++
++The only thing the host adds to a fresh QuickJS context is the frozen global
++`game`. Everything else is the language's own intrinsics: no `require`,
++`process`, `fs`, network, timers, `console` or QuickJS `std`/`os`.
++
++| call | does |
++| --------------------- | ---------------------------------------------------------------- |
++| `game.players()` | every player: `[{ id, name, pos: [x, y, z], yaw }]` (a copy) |
++| `game.player(id)` | one player, or `null` |
++| `game.emit(intent)` | queues an intent; a malformed one throws inside the script |
++| `game.log(...values)` | one `script mod log` line in the server log (4 per tick at most) |
++
++Intents (at most 16 per call):
++
++| intent | applied by |
++| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
++| `{ kind: 'announce', text }` (1–200 characters) | the same broadcast as server announcements |
++| `{ kind: 'giveItem', playerId, item, count }` (1–100) | the player's inventory (`canFit`, `add`, saved, sent to the client); refused if the player is gone, the item unknown or the inventory full |
++
++Intents are applied after the call returns, and only if it completed: a call
++stopped by a budget or ended by a throw changes nothing. An intent is rebuilt
++from its known fields, so nothing else the script put on it (a `__proto__`
++key included) reaches the host. Only JSON crosses the boundary, in both
++directions.
++
++### Budgets
++
++| budget | default | ceiling | enforced by |
++| ---------- | ------- | ------- | -------------------------------------------------------------- |
++| `cpuMs` | 2 | 10 | the QuickJS interrupt handler (wall clock on the tick thread) |
++| `memoryMb` | 8 | 32 | the mod's WASM memory, capped at the 16 MiB engine base + this |
++| `stackKb` | 128 | 256 | the runtime's max stack size |
++
++`cpuMs` is per tick: every call the mod gets in one tick (its `onTick` and any
++join/leave hooks) shares it, and it caps each call. Promise jobs a call queued
++run inside the same deadline, one job at a time. The top-level code gets
++25 × `cpuMs` once.
++
++`memoryMb` is the heap a mod may hold beyond the engine's own fixed base.
++QuickJS's malloc limit cannot count bytes under emscripten (it has no
++`malloc_usable_size`), so the bound is the linear memory itself: each mod's
++`WebAssembly.Memory` has a maximum, a refused grow fails the allocation inside
++the script, and that refusal is a breach even if the script catches the
++`out of memory`. The malloc limit stays as a second line against one huge
++allocation. `stackKb` stops at 256 because past it the host's own (native)
++stack runs out first; a path that recurses in C without QuickJS's check (a
++deeply nested `JSON.stringify`) that exhausts the host's stack is caught,
++reported as a `stack` breach and the instance dropped.
++
++An interrupt or out-of-memory that the script catches, or that an async hook
++turns into a rejection, still counts: the breach flag is the host's, not the
++script's.
++
++A mod that goes over a budget is stopped mid-call and **disabled for this
++instance**: its sandbox is freed, `script mod disabled` is logged with the
++reason (`cpu`, `memory`, `stack`), and it is counted. Five throws in a row
++disable it the same way (`error`); a single throw is logged and counted. The
++tick goes on either way. A disabled mod comes back only with a restart.
++
++Counters: `scriptModsRunning`, `scriptModsDisabled`, `scriptModFailures` in the
++metrics log line; per-mod state, budgets, calls, failures by reason and intents
++applied/refused under `mods.scripts` in `GET /api/v1/platform`.
++
++### Isolation
++
++Each mod gets its own WASM module instance, runtime and context
++(`apps/server/src/mods/script/quickjs.ts`), so no heap, global or prototype is
++shared between mods or with the host. Escape tests
++(`apps/server/src/mods/script/quickjs.test.ts`) check that no host object or
++Node global is reachable, that prototype pollution inside a sandbox never
++reaches host objects, and that one mod cannot see another's state.
++
++### The adapter
++
++`apps/server/src/mods/script/sandbox.ts` is the whole contract between the host
++and an engine — `ModSandboxFactory.create(spec)` returning a `ModSandbox` with
++`call(hook, payload, cpuMs)` and `dispose()` — so OpenVibe.Run can implement the
++same interface later (ADR-036) without the host changing. QuickJS
++(`quickjs-emscripten`, exact version pinned in `apps/server/package.json`) is the
++first implementation.
++
++### Not yet
++
++Capability API v2 (per-mod grants for script bindings, key/value storage),
++seeded randomness, hot reload, multi-file mods, and installing script mods
++through the mods API.
ddiff --git a/packages/content/src/index.ts b/packages/content/src/index.ts
iindex 576c759..a289a30 100644
---- a/packages/content/src/index.ts
++++ b/packages/content/src/index.ts
@@@ -7,6 +7,7 @@ export * from './schema/recipe.js'
export * from './schema/skill.js'
export * from './schema/resourceNode.js'
export * from './schema/world.js'
++export * from './schema/scriptMod.js'
export * from './surface.js'
export * from './mapFileV2.js'
export * from './mapDiff.js'
ddiff --git a/packages/content/src/pack.test.ts b/packages/content/src/pack.test.ts
iindex 332f5a3..c88b827 100644
---- a/packages/content/src/pack.test.ts
++++ b/packages/content/src/pack.test.ts
@@@ -210,3 +210,55 @@ describe('Scraplandia default pack', () => {
expect(createContent().world.resourceNodes.map((n) => [n.node, n.pos])).toEqual([])
})
})
++
++describe('games-quickjs@1 mods in a pack', () => {
++ const mod = {
++ format: 'games-quickjs@1',
++ id: 'greeter',
++ version: '1.0.0',
++ entry: 'greeter.js',
++ source:
++ 'function onPlayerJoin(e) { game.emit({ kind: "announce", text: "hi " + e.player.name }) }',
++ hooks: ['onPlayerJoin'],
++ budgets: { cpuMs: 1 },
++ }
++
++ it('validates the manifest and carries the mod into the registry', () => {
++ const content = createContent([{ mods: [mod] }])
++ expect(content.allMods()).toEqual([mod])
++ expect(createContent().allMods()).toEqual([])
++ const bad = mergePackDefs(base, [
++ {
++ mods: [
++ { ...mod, hooks: ['onEverything'] },
++ { ...mod, id: 'b', budgets: { cpuMs: 50 } },
++ ],
++ },
++ {
++ mods: [
++ { ...mod, extra: true },
++ { ...mod, format: 'games-quickjs@2' },
++ ],
++ },
++ ])
++ expect(bad.errors).toHaveLength(4)
++ expect(bad.defs.mods).toBeUndefined()
++ })
++
++ it('refuses a mod id defined twice, even with allowOverride', () => {
++ const r = mergePackDefs(base, [{ mods: [mod] }, { mods: [mod] }], { allowOverride: true })
++ expect(r.errors).toEqual(["pack 1: mod 'greeter' already exists"])
++ })
++
++ it('pins the source and manifest in the def-set digest', () => {
++ const plain = createContent().digest
++ const withMod = createContent([{ mods: [mod] }]).digest
++ const edited = createContent([{ mods: [{ ...mod, source: mod.source + ' ' }] }]).digest
++ const rebudgeted = createContent([{ mods: [{ ...mod, budgets: { cpuMs: 2 } }] }]).digest
++ expect(new Set([plain, withMod, edited, rebudgeted]).size).toBe(4)
++ // A def set without mods keeps the digest it had before mods existed.
++ expect(contentDigest(base)).toBe(
++ contentDigest({ ...base, mods: undefined } as unknown as ContentDefs),
++ )
++ })
++})
ddiff --git a/packages/content/src/pack.ts b/packages/content/src/pack.ts
iindex f87f482..ae49f6a 100644
---- a/packages/content/src/pack.ts
++++ b/packages/content/src/pack.ts
@@@ -22,6 +22,7 @@ import { ItemDefSchema, type ItemDef } from './schema/item.js'
import { RecipeSchema, type Recipe } from './schema/recipe.js'
import type { ContentDefs } from './registry.js'
import { WorldDefSchema, type WorldDef } from './schema/world.js'
++import { ScriptModSchema, type ScriptMod } from './schema/scriptMod.js'
/** The pack format version both sides must agree on. */
export const CONTENT_PACK_VERSION = 2
@@@ -29,6 +30,9 @@ export const CONTENT_PACK_VERSION = 2
/** Most defs of each kind one pack may carry (the manifest schema uses the same bounds). */
export const MAX_PACK_DEFS = { items: 200, recipes: 200, crops: 100, npcs: 100 } as const
++/** Most `games-quickjs@1` script mods one pack may carry. */
++export const MAX_PACK_MODS = 8
++
/** The def sections a pack may carry. */
export type PackDefKind = keyof typeof MAX_PACK_DEFS
@@@ -38,10 +42,13 @@ export interface ContentPackV2 {
map?: WorldDef
announcements?: { text: string; everySeconds: number }[]
props?: { key: string; item: string; pos: [number, number, number]; yaw?: number }[]
++ /** `games-quickjs@1` server script mods (schema/scriptMod.ts); they are part of the def set. */
++ mods?: unknown[]
}
/** What a merge reads from the base: the four def lists a pack extends and the factions npcs may join. */
--export type PackBase = Pick<ContentDefs, 'items' | 'recipes' | 'crops' | 'npcs' | 'factions'>
++export type PackBase = Pick<ContentDefs, 'items' | 'recipes' | 'crops' | 'npcs' | 'factions'> &
++ Pick<Partial<ContentDefs>, 'mods'>
export interface MergeOptions {
/** Let a pack def replace a base or earlier-pack def with the same id (default: refused). */
@@@ -111,6 +118,7 @@ export function mergePackDefs<B extends PackBase>(
const recipes = [...base.recipes]
const crops = [...base.crops]
const npcs = [...base.npcs]
++ const mods: ScriptMod[] = [...(base.mods ?? [])]
const errors: string[] = []
let map = 'world' in base ? (base as B & { world: WorldDef }).world : undefined
let mapSeen = false
@@@ -136,6 +144,18 @@ export function mergePackDefs<B extends PackBase>(
const c = parseDefs<CropDef>('crops', CropDefSchema, p.defs?.crops)
const v = parseDefs<NpcArchetype>('npcs', NpcArchetypeSchema, p.defs?.npcs)
mine.push(...i.errors, ...r.errors, ...c.errors, ...v.errors)
++ // A mod id is its identity in logs and budgets: never overridden, whatever `allowOverride` says.
++ if (p.mods !== undefined && (!Array.isArray(p.mods) || p.mods.length > MAX_PACK_MODS)) {
++ mine.push(`mods: must be an array of at most ${MAX_PACK_MODS}`)
++ } else {
++ ;(p.mods ?? []).forEach((raw, k) => {
++ const m = ScriptModSchema.safeParse(raw)
++ if (!m.success) mine.push(`mods[${k}]: ${m.error.message}`)
++ else if (mods.some((d) => d.id === m.data.id))
++ mine.push(`mod '${m.data.id}' already exists`)
++ else mods.push(m.data)
++ })
++ }
const ownItems = addDefs('item', items, i.out, allowOverride, mine)
const ownRecipes = addDefs('recipe', recipes, r.out, allowOverride, mine)
@@@ -168,7 +188,19 @@ export function mergePackDefs<B extends PackBase>(
}
errors.push(...mine.map((e) => where + e))
})
-- return { defs: { ...base, items, recipes, crops, npcs, ...(map ? { world: map } : {}) }, errors }
++ return {
++ defs: {
++ ...base,
++ items,
++ recipes,
++ crops,
++ npcs,
++ ...(map ? { world: map } : {}),
++ // Absent rather than empty, so a def set without mods keeps the digest it always had.
++ ...(mods.length > 0 ? { mods } : {}),
++ },
++ errors,
++ }
}
/** 32-bit FNV-1a over a string; plain integer maths, identical in every JS runtime. */
ddiff --git a/packages/content/src/registry.ts b/packages/content/src/registry.ts
iindex 2093dca..63dd6a6 100644
---- a/packages/content/src/registry.ts
++++ b/packages/content/src/registry.ts
@@@ -12,6 +12,7 @@ import { RecipeSchema, type Recipe } from './schema/recipe.js'
import { ResourceNodeTypeSchema, type ResourceNodeType } from './schema/resourceNode.js'
import { SkillDefSchema, type SkillDef } from './schema/skill.js'
import { WorldDefSchema, type WorldDef } from './schema/world.js'
++import { ScriptModSchema, type ScriptMod } from './schema/scriptMod.js'
import { contentDigest } from './pack.js'
export interface ContentDefs {
@@@ -25,6 +26,8 @@ export interface ContentDefs {
markets: MarketDef[]
jobs: JobDef[]
world: WorldDef
++ /** `games-quickjs@1` server script mods; absent when no pack carries one. */
++ mods?: ScriptMod[]
}
/**
@@@ -45,6 +48,7 @@ export class ContentRegistry {
private readonly factions = new Map<string, FactionDef>()
private readonly markets = new Map<string, MarketDef>()
private readonly jobs = new Map<string, JobDef>()
++ private readonly mods = new Map<string, ScriptMod>()
readonly world: WorldDef
constructor(defs: ContentDefs) {
@@@ -260,6 +264,13 @@ export class ContentRegistry {
}
}
++ for (const raw of defs.mods ?? []) {
++ const parsed = ScriptModSchema.safeParse(raw)
++ if (!parsed.success) errors.push(`mod '${raw.id}': ${parsed.error.message}`)
++ else if (this.mods.has(parsed.data.id)) errors.push(`duplicate mod id '${parsed.data.id}'`)
++ else this.mods.set(parsed.data.id, parsed.data)
++ }
++
if (errors.length > 0) {
throw new Error(`Content validation failed:\n - ${errors.join('\n - ')}`)
}
@@@ -339,6 +350,11 @@ export class ContentRegistry {
return [...this.factions.values()]
}
++ /** The `games-quickjs@1` script mods in the def set (run only where the place enables them). */
++ allMods(): readonly ScriptMod[] {
++ return [...this.mods.values()]
++ }
++
market(id: string): MarketDef | undefined {
return this.markets.get(id)
}
ddiff --git a/packages/content/src/schema/scriptMod.ts b/packages/content/src/schema/scriptMod.ts
nnew file mode 100644
iindex 0000000..1db10df
---- /dev/null
++++ b/packages/content/src/schema/scriptMod.ts
@@@ -0,0 +1,61 @@
++import { z } from 'zod'
++
++/**
++ * `games-quickjs@1`: a server script mod carried by a content pack. The
++ * manifest and the source travel together, so the def-set digest (pack.ts)
++ * pins both: a client whose copy of a mod differs by one byte is refused at
++ * the handshake like any other content difference.
++ *
++ * The server runs it in a QuickJS sandbox (apps/server/src/mods/script) only
++ * when the place enables script mods; see docs/mods.md for the host API.
++ */
++export const SCRIPT_MOD_FORMAT = 'games-quickjs@1'
++
++/** The hooks a mod may declare; the host calls only declared hooks. */
++export const SCRIPT_MOD_HOOKS = ['onTick', 'onPlayerJoin', 'onPlayerLeave'] as const
++export type ScriptModHook = (typeof SCRIPT_MOD_HOOKS)[number]
++
++/**
++ * Budget defaults and ceilings, per mod. `cpuMs` is per tick (every hook call
++ * the mod gets in one tick shares it) and caps any single call; `memoryMb` is
++ * the heap the mod may hold beyond the engine's fixed 16 MiB base; `stackKb`
++ * its maximum stack.
++ */
++export const SCRIPT_MOD_BUDGETS = {
++ cpuMs: { default: 2, max: 10 },
++ memoryMb: { default: 8, max: 32 },
++ // Past ~256 KiB of QuickJS stack the host's own (native) stack runs out first.
++ stackKb: { default: 128, max: 256 },
++} as const
++
++/** Longest source a mod may carry, in UTF-16 code units. */
++export const MAX_SCRIPT_SOURCE = 64 * 1024
++
++const slug = /^[a-z0-9][a-z0-9_-]{0,39}$/
++
++export const ScriptModSchema = z
++ .object({
++ format: z.literal(SCRIPT_MOD_FORMAT),
++ id: z.string().regex(slug),
++ version: z.string().regex(/^\d{1,4}\.\d{1,4}\.\d{1,4}$/),
++ /** The script's file name, used in stack traces; v1 mods are one script (no module loader). */
++ entry: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,39}\.js$/),
++ source: z.string().min(1).max(MAX_SCRIPT_SOURCE),
++ hooks: z
++ .array(z.enum(SCRIPT_MOD_HOOKS))
++ .min(1)
++ .max(SCRIPT_MOD_HOOKS.length)
++ .refine((h) => new Set(h).size === h.length, 'hooks must be unique'),
++ /** Requested budgets; anything omitted takes the default. */
++ budgets: z
++ .object({
++ cpuMs: z.number().positive().max(SCRIPT_MOD_BUDGETS.cpuMs.max).optional(),
++ memoryMb: z.number().int().min(1).max(SCRIPT_MOD_BUDGETS.memoryMb.max).optional(),
++ stackKb: z.number().int().min(64).max(SCRIPT_MOD_BUDGETS.stackKb.max).optional(),
++ })
++ .strict()
++ .optional(),
++ })
++ .strict()
++
++export type ScriptMod = z.infer<typeof ScriptModSchema>
ddiff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
iindex 6083be8..5897fc5 100644
---- a/pnpm-lock.yaml
++++ b/pnpm-lock.yaml
@@@ -123,6 +123,9 @@ importers:
pg:
specifier: ^8.23.0
version: 8.23.0
++ quickjs-emscripten:
++ specifier: 0.32.0
++ version: 0.32.0
tsx:
specifier: ^4.19.3
version: 4.23.11
@@@ -659,6 +662,21 @@ packages:
'@iovalkey/[email protected]':
resolution: {integrity: sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==}
++ '@jitl/[email protected]':
++ resolution: {integrity: sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==}
++
++ '@jitl/[email protected]':
++ resolution: {integrity: sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==}
++
++ '@jitl/[email protected]':
++ resolution: {integrity: sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==}
++
++ '@jitl/[email protected]':
++ resolution: {integrity: sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==}
++
++ '@jitl/[email protected]':
++ resolution: {integrity: sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==}
++
'@jridgewell/[email protected]':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
@@@ -1456,6 +1474,13 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
++ resolution: {integrity: sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==}
++
++ resolution: {integrity: sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==}
++ engines: {node: '>=16.0.0'}
++
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
@@@ -2072,6 +2097,24 @@ snapshots:
'@iovalkey/[email protected]': {}
++ '@jitl/[email protected]': {}
++
++ '@jitl/[email protected]':
++ dependencies:
++ '@jitl/quickjs-ffi-types': 0.32.0
++
++ '@jitl/[email protected]':
++ dependencies:
++ '@jitl/quickjs-ffi-types': 0.32.0
++
++ '@jitl/[email protected]':
++ dependencies:
++ '@jitl/quickjs-ffi-types': 0.32.0
++
++ '@jitl/[email protected]':
++ dependencies:
++ '@jitl/quickjs-ffi-types': 0.32.0
++
'@jridgewell/[email protected]': {}
'@napi-rs/[email protected]':
@@@ -2879,6 +2922,18 @@ snapshots:
++ dependencies:
++ '@jitl/quickjs-ffi-types': 0.32.0
++
++ dependencies:
++ '@jitl/quickjs-wasmfile-debug-asyncify': 0.32.0
++ '@jitl/quickjs-wasmfile-debug-sync': 0.32.0
++ '@jitl/quickjs-wasmfile-release-asyncify': 0.32.0
++ '@jitl/quickjs-wasmfile-release-sync': 0.32.0
++ quickjs-emscripten-core: 0.32.0
++
ddiff --git a/test/apps__server__src__mods__script__host.test.js b/test/apps__server__src__mods__script__host.test.js
nnew file mode 100644
iindex 0000000..24abced
---- /dev/null
++++ b/test/apps__server__src__mods__script__host.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/server/src/mods/script/host.test.ts'
++// covers: 'apps/server/src/mods/script'
++// Generated by node test/run.js --index; do not edit.
ddiff --git a/test/apps__server__src__mods__script__quickjs.test.js b/test/apps__server__src__mods__script__quickjs.test.js
nnew file mode 100644
iindex 0000000..5981c8b
---- /dev/null
++++ b/test/apps__server__src__mods__script__quickjs.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/server/src/mods/script/quickjs.test.ts'
++// covers: 'apps/server/src/mods/script'
++// Generated by node test/run.js --index; do not edit.