import { Type } from "@sinclair/typebox"; import { expect, it } from "bun:test"; import { ExtendedPatch } from "../extended"; import { ServerScripts } from "../multiplayerPolicies"; import type { InputQueueItem } from "../serverScripts"; import { ServerScriptManager } from "../serverScripts"; import { createUnsafeQuickJSModule } from "../serverSimulator"; import type { MultiplayerUser } from "../sync/types"; function createSchema(code: string) { return ServerScripts( Type.Object({ count: Type.Number(), }), [ { id: "increment", code, on: { stateChange: true, }, }, ] ); } const testEnvironment = { target: "test" as const, mode: "simulation" as const, label: "server-sim", }; it("runs server scripts using the unsafe QuickJS shim", async () => { const schema = createSchema(` export default function loop(ctx) { const data = ctx.getSharedConnectionData() ?? {}; const runs = (data.runs ?? 0) + 1; ctx.setSharedConnectionData({ runs }); return { ...ctx.state, count: ctx.state.count + 1, }; } `); let state = { count: 0 }; let sharedData: Record = {}; let resolveApply: (() => void) | undefined; let rejectApply: ((error: Error) => void) | undefined; const logs: { level: string; values: unknown[] }[] = []; const waitForApply = () => new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error(`server script timeout logs=${JSON.stringify(logs)}`)); }, 2000); resolveApply = () => { clearTimeout(timeout); resolve(); }; rejectApply = (error) => { clearTimeout(timeout); reject(error); }; }); const manager = new ServerScriptManager({ getQuickJSModule: () => Promise.resolve(createUnsafeQuickJSModule()), environment: testEnvironment, getState: () => state, getSharedConnectionData: () => sharedData, setSharedConnectionData: (data) => { sharedData = data; }, drainInputQueue: () => [], applyState: async ({ nextState }) => { state = nextState; resolveApply?.(); resolveApply = undefined; rejectApply = undefined; }, log: (entry) => { logs.push({ level: entry.level, values: entry.values }); if (entry.level === "error" && rejectApply) { const error = entry.values[0] instanceof Error ? (entry.values[0] as Error) : new Error(String(entry.values[0])); rejectApply(error); } }, }); await manager.updateSchema(schema); manager.setIsHot(true); const applied = waitForApply(); manager.handleStateChange([ { op: "replace", path: ["count"], value: 1, } as ExtendedPatch, ]); await applied; expect(state.count).toEqual(1); expect(sharedData).toEqual({ runs: 1 }); expect(logs.filter((entry) => entry.level === "error").length).toEqual(0); }); it("logs via console", async () => { const schema = createSchema(` export default function loop(ctx) { console.log("server log", ctx.state.count); return { ...ctx.state, count: ctx.state.count + 1, }; } `); let state = { count: 0 }; let sharedData: Record = {}; let resolveApply: (() => void) | undefined; const logs: { level: string; values: unknown[] }[] = []; const waitForApply = () => new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error("server script timeout")); }, 0); resolveApply = () => { clearTimeout(timeout); resolve(); }; }); const manager = new ServerScriptManager({ getQuickJSModule: () => Promise.resolve(createUnsafeQuickJSModule()), environment: testEnvironment, getState: () => state, getSharedConnectionData: () => sharedData, setSharedConnectionData: (data) => { sharedData = data; }, drainInputQueue: () => [], applyState: async ({ nextState }) => { state = nextState; resolveApply?.(); resolveApply = undefined; }, log: (entry) => { logs.push({ level: entry.level, values: entry.values }); }, }); await manager.updateSchema(schema); manager.setIsHot(true); const applied = waitForApply(); manager.handleStateChange([ { op: "replace", path: ["count"], value: 1, } as ExtendedPatch, ]); await applied; const logEntry = logs.find((entry) => entry.level === "log"); expect(logEntry).toBeDefined(); expect(logEntry?.values).toEqual(["server log", 0]); }); it("consumes input queues when triggered", async () => { const schema = ServerScripts( Type.Object({ count: Type.Number(), }), [ { id: "queue-consumer", code: ` export default function loop(ctx) { const items = ctx.dequeueInput({ queue: "controller" }) ?? []; const delta = items.reduce((sum, item) => { const value = (item.payload && item.payload.delta) || 0; return sum + value; }, 0); return { ...ctx.state, count: ctx.state.count + delta, }; } `, on: { inputQueue: { queues: ["controller"] }, }, }, ] ); let state = { count: 0 }; let resolveApply: (() => void) | undefined; const waitForApply = () => new Promise((resolve) => { resolveApply = () => { resolve(); resolveApply = undefined; }; }); const queued: InputQueueItem[] = [ { id: "a", connectionId: "one", queue: "controller", enqueuedAt: Date.now(), payload: { delta: 2 }, }, { id: "b", connectionId: "two", queue: "controller", enqueuedAt: Date.now(), payload: { delta: 3 }, }, ]; const manager = new ServerScriptManager({ getQuickJSModule: () => Promise.resolve(createUnsafeQuickJSModule()), environment: testEnvironment, getState: () => state, getSharedConnectionData: () => ({}), setSharedConnectionData: () => { // }, drainInputQueue: () => queued.splice(0), applyState: async ({ nextState }) => { state = nextState; resolveApply?.(); }, log: (_entry) => { // }, }); await manager.updateSchema(schema); manager.setIsHot(true); const applied = waitForApply(); manager.handleInputQueueActivity("controller"); await applied; expect(state.count).toEqual(5); expect(queued.length).toEqual(0); }); it("runs input queue triggers that listen to every queue", async () => { const schema = ServerScripts( Type.Object({ count: Type.Number(), }), [ { id: "any-queue", code: ` export default function loop(ctx) { const items = ctx.dequeueInput() ?? []; const delta = items.reduce((sum, item) => sum + (item.payload?.delta ?? 0), 0); return { ...ctx.state, count: ctx.state.count + delta, }; } `, on: { inputQueue: true, }, }, ] ); let state = { count: 0 }; const queued: InputQueueItem[] = [ { id: "a", connectionId: "one", queue: "controller", enqueuedAt: Date.now(), payload: { delta: 4 }, }, { id: "b", connectionId: "one", queue: "another", enqueuedAt: Date.now(), payload: { delta: 1 }, }, ]; let resolveApply: (() => void) | undefined; const waitForApply = () => new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error("timeout")); }, 1000); resolveApply = () => { clearTimeout(timeout); resolve(); resolveApply = undefined; }; }); const manager = new ServerScriptManager({ getQuickJSModule: () => Promise.resolve(createUnsafeQuickJSModule()), environment: testEnvironment, getState: () => state, getSharedConnectionData: () => ({}), setSharedConnectionData: () => { // }, drainInputQueue: () => queued.splice(0), applyState: async ({ nextState }) => { state = nextState; resolveApply?.(); }, log: (_entry) => { // }, }); await manager.updateSchema(schema); manager.setIsHot(true); const applied = waitForApply(); manager.handleInputQueueActivity(); await applied; expect(state.count).toEqual(5); expect(queued.length).toEqual(0); }); it("supports scripts that combine stateChange and inputQueue triggers", async () => { const schema = ServerScripts( Type.Object({ count: Type.Number(), }), [ { id: "combined", code: ` export default function loop(ctx) { const items = ctx.dequeueInput({ queue: "controller" }) ?? []; const delta = items.reduce((sum, item) => sum + (item.payload?.delta ?? 0), 0); return { ...ctx.state, count: ctx.state.count + 1 + delta, }; } `, on: { stateChange: true, inputQueue: { queues: ["controller"] }, }, }, ] ); let state = { count: 0 }; const queued: InputQueueItem[] = [ { id: "queue", connectionId: "one", queue: "controller", enqueuedAt: Date.now(), payload: { delta: 2 }, }, ]; let allowDrain = false; let resolveApply: (() => void) | undefined; const waitForApply = () => new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error("timeout")), 1000); resolveApply = () => { clearTimeout(timeout); resolve(); resolveApply = undefined; }; }); const manager = new ServerScriptManager({ getQuickJSModule: () => Promise.resolve(createUnsafeQuickJSModule()), environment: testEnvironment, getState: () => state, getSharedConnectionData: () => ({}), setSharedConnectionData: () => { // }, drainInputQueue: () => (allowDrain ? queued.splice(0) : []), applyState: async ({ nextState }) => { state = nextState; resolveApply?.(); }, log: (_entry) => { // }, }); await manager.updateSchema(schema); manager.setIsHot(true); let applied = waitForApply(); manager.handleStateChange([ { op: "replace", path: ["count"], value: 1, } as ExtendedPatch, ]); await applied; expect(state.count).toEqual(1); applied = waitForApply(); allowDrain = true; manager.handleInputQueueActivity("controller"); await applied; expect(state.count).toEqual(4); }); it("runs user triggers on join and leave", async () => { const schema = ServerScripts( Type.Object({ events: Type.Array( Type.Object({ type: Type.String(), id: Type.String(), }), { default: [] } ), }), [ { id: "user-events", code: ` export default function loop(ctx) { if (!ctx.trigger || ctx.trigger.type !== "user") return; return { ...ctx.state, events: [...ctx.state.events, { type: ctx.trigger.event, id: ctx.trigger.user.id }], }; } `, on: { user: true }, }, ] ); let state = { events: [] as { type: string; id: string }[] }; const manager = new ServerScriptManager({ getQuickJSModule: () => Promise.resolve(createUnsafeQuickJSModule()), environment: testEnvironment, getState: () => state, getSharedConnectionData: () => ({}), setSharedConnectionData: () => {}, drainInputQueue: () => [], applyState: async ({ nextState }) => { state = nextState; }, log: () => {}, }); await manager.updateSchema(schema); manager.setIsHot(true); const user: MultiplayerUser = { id: "user-a", connectionId: "conn-a", clientId: "client-a", }; manager.handleUserEvent("join", user); manager.handleUserEvent("leave", user); await new Promise((resolve) => setTimeout(resolve, 0)); expect(state.events).toEqual([ { type: "join", id: "user-a" }, { type: "leave", id: "user-a" }, ]); });