import { uuid as createUuid, isDeepEqual } from "@noya-app/noya-utils"; import { ExtendedPatch } from "./extended"; import { getServerScriptDefinitions, ServerScriptDefinition, } from "./multiplayerPolicies"; import type { MultiplayerUser } from "./sync/types"; const DEFAULT_INTERVAL_MS = 100; type IntervalHandle = ReturnType | null; type ExecutionContext = { state: any; now: number; scriptId: string; origin?: string; invocation: number; trigger?: | { type: "user"; event: ServerScriptUserTriggerEvent; user: MultiplayerUser; } | { type: "schemaMigration"; fromVersion: number; toVersion: number; }; environment: { target: ServerScriptTarget; mode: ServerScriptMode; label: string; }; }; type PathMatcher = string[]; export type InputQueueItem = { id: string; connectionId: string; queue: string; enqueuedAt: number; payload: unknown; }; export type DrainInputQueueOptions = { connectionId?: string; queue?: string; maxEntries?: number; }; export type QuickJSHandleLike = { consume(fn: (handle: QuickJSHandleLike) => T): T; dispose(): void; }; export type QuickJSContextLike = { global: QuickJSHandleLike; undefined: QuickJSHandleLike; evalCode( code: string, filename?: string, options?: { type?: "global" | "module" } ): QuickJSHandleLike; unwrapResult(handle: QuickJSHandleLike): QuickJSHandleLike; newFunction( name: string, fn: (...args: QuickJSHandleLike[]) => QuickJSHandleLike ): QuickJSHandleLike; newObject(): QuickJSHandleLike; newString(value: string): QuickJSHandleLike; setProp( object: QuickJSHandleLike, prop: string, value: QuickJSHandleLike ): void; dump(handle: QuickJSHandleLike): T; dispose(): void; }; export type QuickJSRuntimeLike = { newContext(): QuickJSContextLike; setModuleLoader( loader: ( moduleName: string ) => string | { error: Error } | Promise ): void; dispose(): void; }; export type QuickJSModuleLike = { newRuntime(): QuickJSRuntimeLike; }; export type QuickJSModuleFactory< TModule extends QuickJSModuleLike = QuickJSModuleLike, > = () => Promise | TModule; export type ServerScriptTarget = | "durable-object" | "local-storage" | "client-simulator" | "test"; type ServerScriptUserTriggerEvent = "join" | "leave"; export type ServerScriptMode = "live" | "simulation"; export type ServerScriptEnvironment = { target: ServerScriptTarget; mode?: ServerScriptMode; label?: string; clock?: () => number; }; export type ServerScriptLogEvent = { level: "log" | "error" | "warn"; scriptId: string; target: ServerScriptTarget; mode: ServerScriptMode; origin: string; values: unknown[]; }; export type ServerScriptRuntimeBindings = { getState: () => any; getSharedConnectionData: () => Record; setSharedConnectionData: (data: Record) => void; drainInputQueue: (options?: DrainInputQueueOptions) => InputQueueItem[]; applyState: (options: { scriptId: string; nextState: any; invocation?: number; timestamp?: number; }) => Promise | void; getInitialInvocation?: (scriptId: string) => number | undefined; }; export type ServerScriptManagerOptions< TModule extends QuickJSModuleLike = QuickJSModuleLike, > = ServerScriptRuntimeBindings & { getQuickJSModule: QuickJSModuleFactory; randomUUID?: () => string; environment: ServerScriptEnvironment; log: (entry: ServerScriptLogEvent) => void; }; export function serializeServerScriptLogValue( value: unknown, seen = new WeakSet() ): unknown { if (value === null) return null; switch (typeof value) { case "undefined": return `[undefined]`; case "string": case "number": case "boolean": return value; case "bigint": return value.toString(); case "symbol": return value.toString(); case "function": { const fn = value as Function; return `[Function ${fn.name || "anonymous"}]`; } } if (value instanceof Error) { return { name: value.name, message: value.message, stack: value.stack, }; } if (value instanceof Date) { return `[Date ${value.toISOString()}]`; } if (Array.isArray(value)) { if (seen.has(value)) return "[Circular]"; seen.add(value); const serialized = value.map((item) => serializeServerScriptLogValue(item, seen) ); seen.delete(value); return serialized; } if (typeof value === "object" && value !== null) { const objectValue = value as Record; if (seen.has(objectValue)) { return "[Circular]"; } seen.add(objectValue); const serialized: Record = {}; for (const key of Object.keys(objectValue)) { serialized[key] = serializeServerScriptLogValue(objectValue[key], seen); } seen.delete(objectValue); return serialized; } return value; } function parsePathMatcher(path: string): PathMatcher { return path .split(".") .map((segment) => segment.trim()) .filter((segment) => segment.length > 0); } function toPatchPathSegments(patch: ExtendedPatch): string[] { return (patch.path ?? []).map((segment) => { if (typeof segment === "number") return segment.toString(); if (typeof segment === "object" && segment !== null && "key" in segment) { return String((segment as any).key); } return String(segment); }); } function doesPathMatch(patchSegments: string[], matcher: PathMatcher) { if (matcher.length === 0) return false; if (matcher.length > patchSegments.length) return false; for (let i = 0; i < matcher.length; i += 1) { const segment = matcher[i]; if (segment === "*") continue; if (segment !== patchSegments[i]) return false; } return true; } function parseIntervalValue(value?: string | null): number | null { if (!value) return null; const trimmed = value.trim(); if (!trimmed) return null; const match = trimmed.match(/^(\d+)(ms|s)$/i); if (!match) { return DEFAULT_INTERVAL_MS; } const amount = Number(match[1]); const unit = match[2].toLowerCase(); if (Number.isNaN(amount)) return DEFAULT_INTERVAL_MS; return unit === "s" ? amount * 1000 : amount; } function getDefaultRandomUUID() { if ( typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ) { return () => crypto.randomUUID(); } return () => createUuid(); } type ResolvedServerScriptEnvironment = { target: ServerScriptTarget; mode: ServerScriptMode; label: string; clock: () => number; }; function resolveEnvironment( environment: ServerScriptEnvironment ): ResolvedServerScriptEnvironment { const mode = environment.mode ?? "live"; return { target: environment.target, mode, label: environment.label ?? `${environment.target}${mode === "simulation" ? "-sim" : ""}`, clock: environment.clock ?? (() => Date.now()), }; } class ServerScriptRunner { private vm: QuickJSContextLike | null = null; private runtime: QuickJSRuntimeLike | null = null; private interval: IntervalHandle = null; private disposed = false; private active = false; private isRunning = false; private readyPromise: Promise | null = null; private invocation = 0; private stateChangeMatchers: PathMatcher[] = []; private runsOnStateChange = false; private hasIntervalTrigger = false; private intervalDurationMs: number | null = null; private runOnAnyStateChange = false; private runsOnInputQueue = false; private runOnAnyInputQueue = false; private runsOnUserEvent = false; private runOnAnyUserEvent = false; private runsOnSchemaMigration = false; private runOnAnyMigration = false; private pendingRun = false; private inputQueueMatchers: string[] = []; private userEventMatchers: ServerScriptUserTriggerEvent[] = []; private migrationVersionMatchers: number[] = []; private lastTickTimestamp: number | null = null; private host: ServerScriptRuntimeBindings; private environment: ResolvedServerScriptEnvironment; private logSink: (entry: ServerScriptLogEvent) => void; private pendingTriggers: ExecutionContext["trigger"][] = []; constructor( private definition: ServerScriptDefinition, private quickJSPromise: Promise, host: ServerScriptRuntimeBindings, environment: ResolvedServerScriptEnvironment, logSink: (entry: ServerScriptLogEvent) => void, private randomUUID: () => string ) { this.host = host; this.environment = environment; this.logSink = logSink; this.updateTriggersFromDefinition(); this.lastTickTimestamp = null; this.invocation = host.getInitialInvocation?.(this.definition.id) ?? 0; } get id() { return this.definition.id; } get intervalMs() { return this.intervalDurationMs ?? DEFAULT_INTERVAL_MS; } private getModuleSpecifier() { return `server-script:${encodeURIComponent(this.definition.id)}`; } syncInvocation(invocation: number, timestamp?: number) { if (invocation > this.invocation) { this.invocation = invocation; } const now = this.getNow(); if (timestamp !== undefined) { this.lastTickTimestamp = Math.max(timestamp, now); } else { this.lastTickTimestamp = now; } // Reset scheduling based on the new anchor this.stopInterval(); if (this.active && this.hasIntervalTrigger) { this.scheduleNextInterval(); } } isSameDefinition(definition: ServerScriptDefinition) { return ( definition.code === this.definition.code && isDeepEqual(definition.on ?? {}, this.definition.on ?? {}) ); } updateDefinition(definition: ServerScriptDefinition) { const codeChanged = definition.code !== this.definition.code; const previousInterval = this.intervalDurationMs; const previousHasInterval = this.hasIntervalTrigger; const previousRunsOnStateChange = this.runsOnStateChange; const previousRunAny = this.runOnAnyStateChange; const previousMatchers = this.stateChangeMatchers.map((matcher) => [ ...matcher, ]); const previousRunsOnInputQueue = this.runsOnInputQueue; const previousRunAnyInputQueue = this.runOnAnyInputQueue; const previousInputQueueMatchers = [...this.inputQueueMatchers]; this.definition = definition; this.invocation = this.host.getInitialInvocation?.(definition.id) ?? 0; this.updateTriggersFromDefinition(); const intervalChanged = previousInterval !== this.intervalDurationMs || previousHasInterval !== this.hasIntervalTrigger; const stateChangeChanged = previousRunsOnStateChange !== this.runsOnStateChange || previousRunAny !== this.runOnAnyStateChange || !isDeepEqual(previousMatchers, this.stateChangeMatchers); const inputQueueChanged = previousRunsOnInputQueue !== this.runsOnInputQueue || previousRunAnyInputQueue !== this.runOnAnyInputQueue || !isDeepEqual(previousInputQueueMatchers, this.inputQueueMatchers); if (codeChanged) { this.disposeVm(); } if (intervalChanged) { if (this.hasIntervalTrigger) { if (this.active) { this.restartInterval(); } } else { this.stopInterval(); } } if ( (codeChanged || stateChangeChanged || inputQueueChanged) && this.active && (this.runsOnStateChange || this.runsOnInputQueue) ) { void this.ensureVm(); } } setActive(active: boolean) { if (this.disposed) return; if (this.active === active) return; this.active = active; if (active) { if (this.hasIntervalTrigger) { void this.ensureInterval(); } if ( (this.runsOnStateChange || this.runsOnInputQueue) && !this.hasIntervalTrigger ) { void this.ensureVm(); } } else { this.stopInterval(); } } dispose() { this.disposed = true; this.stopInterval(); this.disposeVm(); } private async ensureInterval() { if (this.interval || this.disposed || !this.active) return; if (!this.hasIntervalTrigger) return; await this.ensureVm(); if (this.disposed || !this.active) return; // Run immediately using current server time, then schedule next aligned tick void this.tick(this.getNow()); } private restartInterval() { this.stopInterval(); if (this.active) { void this.ensureInterval(); } } private stopInterval() { if (this.interval === null) return; clearTimeout(this.interval); this.interval = null; } private getNow() { return this.environment.clock(); } private log(level: "log" | "error" | "warn", ...values: unknown[]) { this.logSink({ level, scriptId: this.definition.id, target: this.environment.target, mode: this.environment.mode, origin: this.environment.label, values, }); } private scheduleNextInterval() { if (this.interval || this.disposed || !this.active) return; if (!this.hasIntervalTrigger) return; const now = this.getNow(); const base = this.lastTickTimestamp ?? now; const target = base + this.intervalMs; const delay = Math.max(0, target - now); this.interval = setTimeout(() => { this.interval = null; void this.tick(target); }, delay); } private disposeVm() { this.vm?.dispose(); this.vm = null; this.runtime?.dispose(); this.runtime = null; this.readyPromise = null; } private async ensureVm() { if (this.vm || this.readyPromise) { return this.readyPromise; } this.readyPromise = (async () => { const QuickJS = await this.quickJSPromise; if (this.disposed) return; const runtime = QuickJS.newRuntime(); const moduleSpecifier = this.getModuleSpecifier(); runtime.setModuleLoader((moduleName) => { if (moduleName === moduleSpecifier) { return this.definition.code; } return { error: new Error(`Unknown module ${moduleName}`) }; }); const vm = runtime.newContext(); this.vm = vm; this.runtime = runtime; const consoleHandle = vm.newObject(); const createConsoleFunction = (level: "log" | "error" | "warn") => vm.newFunction(level, (...args) => { const values = args.map((arg) => vm.dump(arg)); this.log(level, ...values); return vm.undefined; }); const logHandle = createConsoleFunction("log"); const warnHandle = createConsoleFunction("warn"); const errorHandle = createConsoleFunction("error"); vm.setProp(consoleHandle, "log", logHandle); vm.setProp(consoleHandle, "warn", warnHandle); vm.setProp(consoleHandle, "error", errorHandle); logHandle.dispose(); warnHandle.dispose(); errorHandle.dispose(); vm.setProp(vm.global, "console", consoleHandle); consoleHandle.dispose(); const cryptoHandle = vm.newObject(); const randomUuidHandle = vm.newFunction("randomUUID", () => { return vm.newString(this.randomUUID()); }); vm.setProp(cryptoHandle, "randomUUID", randomUuidHandle); randomUuidHandle.dispose(); vm.setProp(vm.global, "crypto", cryptoHandle); cryptoHandle.dispose(); const getSharedHandle = vm.newFunction( "__noyaGetSharedConnectionData", () => { const data = this.host.getSharedConnectionData(); return vm.newString(JSON.stringify(data ?? {})); } ); vm.setProp(vm.global, "__noyaGetSharedConnectionData", getSharedHandle); const setSharedHandle = vm.newFunction( "__noyaSetSharedConnectionData", (dataHandle) => { const value = vm.dump(dataHandle); if (typeof value !== "object" || value === null) { throw new Error( "setSharedConnectionData requires an object argument" ); } this.host.setSharedConnectionData(value as Record); return vm.undefined; } ); vm.setProp(vm.global, "__noyaSetSharedConnectionData", setSharedHandle); const drainInputQueueHandle = vm.newFunction( "__noyaDrainInputQueue", (...args) => { const optionsHandle = args[0]; let options: DrainInputQueueOptions | undefined; if (optionsHandle) { const dumped = vm.dump(optionsHandle); if (dumped && typeof dumped === "object") { const obj = dumped as Record; options = { connectionId: typeof obj.connectionId === "string" ? obj.connectionId : undefined, queue: typeof obj.queue === "string" ? obj.queue : undefined, maxEntries: typeof obj.maxEntries === "number" ? obj.maxEntries : undefined, }; } } const entries = this.host.drainInputQueue(options); return vm.newString(JSON.stringify(entries ?? [])); } ); vm.setProp(vm.global, "__noyaDrainInputQueue", drainInputQueueHandle); const bootstrapSource = ` import loop from "${moduleSpecifier}"; if (typeof loop !== 'function') { throw new Error('export default function is required in server script ${this.definition.id}'); } const getSharedConnectionData = () => JSON.parse(globalThis.__noyaGetSharedConnectionData()); const setSharedConnectionData = globalThis.__noyaSetSharedConnectionData; const dequeueInput = (options) => JSON.parse(globalThis.__noyaDrainInputQueue(options)); globalThis.__noyaGetSharedConnectionDataProxy = getSharedConnectionData; globalThis.__noyaDrainInputQueueProxy = dequeueInput; globalThis.__noyaLoop = (ctx) => loop({ ...ctx, getSharedConnectionData, setSharedConnectionData, dequeueInput, }); `; vm.unwrapResult( vm.evalCode(bootstrapSource, `${this.definition.id}.bootstrap.mjs`, { type: "module", }) ).dispose(); })(); try { await this.readyPromise; } finally { this.readyPromise = null; } } private async tick(targetNow?: number) { if (this.disposed || this.isRunning) return; const vm = this.vm; if (!vm) return; let state = this.host.getState(); if (state === undefined) return; this.isRunning = true; try { const now = targetNow ?? this.getNow(); const seedInvocation = this.host.getInitialInvocation?.( this.definition.id ); if (seedInvocation !== undefined && seedInvocation > this.invocation) { this.invocation = seedInvocation; } const nextState = await this.runSingleTick(now, state, vm); if (nextState !== undefined) { state = nextState; } this.lastTickTimestamp = now; this.scheduleNextInterval(); } catch (error) { this.log("error", error); } finally { this.isRunning = false; if (this.pendingTriggers.length > 0) { this.pendingRun = true; } if (this.pendingRun) { this.pendingRun = false; void this.runNow(); } } } private async runSingleTick(now: number, state: any, vm: QuickJSContextLike) { this.invocation += 1; const invocation = this.invocation; const trigger = this.pendingTriggers.shift(); const context: ExecutionContext = { state, now, scriptId: this.definition.id, origin: this.environment.label, invocation, ...(trigger ? { trigger } : {}), environment: { target: this.environment.target, mode: this.environment.mode, label: this.environment.label, }, }; const contextJson = JSON.stringify(context); const source = `(() => { const ctx = JSON.parse(${JSON.stringify( contextJson )}); ctx.getSharedConnectionData = globalThis.__noyaGetSharedConnectionDataProxy; ctx.setSharedConnectionData = globalThis.__noyaSetSharedConnectionData; ctx.dequeueInput = globalThis.__noyaDrainInputQueueProxy; return globalThis.__noyaLoop(ctx); })()`; let resultValue: any = undefined; vm.unwrapResult( vm.evalCode(source, `${this.definition.id}-tick.js`, { type: "global", }) ).consume((result) => { resultValue = vm.dump(result); }); const nextState = this.normalizeResult(resultValue); if (nextState === undefined) return undefined; await this.host.applyState({ scriptId: this.definition.id, nextState, invocation, timestamp: now, }); return nextState; } private normalizeResult(value: unknown) { if (value === null || value === undefined) { return undefined; } if (typeof value === "object") { return value; } return undefined; } private updateTriggersFromDefinition() { const triggers = this.definition.on ?? {}; const intervalMs = parseIntervalValue(triggers.interval); this.intervalDurationMs = intervalMs; this.hasIntervalTrigger = intervalMs !== null; const stateChangeTrigger = triggers.stateChange; if (stateChangeTrigger === true) { this.runsOnStateChange = true; this.runOnAnyStateChange = true; this.stateChangeMatchers = []; } else if ( stateChangeTrigger && typeof stateChangeTrigger === "object" && Array.isArray(stateChangeTrigger.paths) ) { this.runsOnStateChange = true; this.runOnAnyStateChange = false; this.stateChangeMatchers = stateChangeTrigger.paths.map(parsePathMatcher); } else { this.runsOnStateChange = false; this.runOnAnyStateChange = false; this.stateChangeMatchers = []; } const inputQueueTrigger = triggers.inputQueue; if (inputQueueTrigger === true) { this.runsOnInputQueue = true; this.runOnAnyInputQueue = true; this.inputQueueMatchers = []; } else if ( inputQueueTrigger && typeof inputQueueTrigger === "object" && Array.isArray(inputQueueTrigger.queues) ) { this.runsOnInputQueue = true; this.runOnAnyInputQueue = false; this.inputQueueMatchers = inputQueueTrigger.queues.map((queue) => String(queue) ); } else { this.runsOnInputQueue = false; this.runOnAnyInputQueue = false; this.inputQueueMatchers = []; } const userTrigger = triggers.user; if (userTrigger === true) { this.runsOnUserEvent = true; this.runOnAnyUserEvent = true; this.userEventMatchers = []; } else if ( userTrigger && typeof userTrigger === "object" && Array.isArray(userTrigger.events) ) { this.runsOnUserEvent = true; this.runOnAnyUserEvent = false; this.userEventMatchers = userTrigger.events.map((event) => event === "leave" ? "leave" : "join" ); } else { this.runsOnUserEvent = false; this.runOnAnyUserEvent = false; this.userEventMatchers = []; } const migrationTrigger = triggers.schemaMigration; if (migrationTrigger === true) { this.runsOnSchemaMigration = true; this.runOnAnyMigration = true; this.migrationVersionMatchers = []; } else if ( migrationTrigger && typeof migrationTrigger === "object" && Array.isArray(migrationTrigger.fromVersions) ) { this.runsOnSchemaMigration = true; this.runOnAnyMigration = false; this.migrationVersionMatchers = migrationTrigger.fromVersions.filter( (v): v is number => typeof v === "number" ); } else { this.runsOnSchemaMigration = false; this.runOnAnyMigration = false; this.migrationVersionMatchers = []; } } private supportsStateChangeTrigger() { return this.runsOnStateChange; } handleStateChange(patches: ExtendedPatch[]) { if (!this.supportsStateChangeTrigger()) return; if (!this.active) return; if (patches.length === 0) return; if (!this.shouldRunForPatches(patches)) return; void this.runNow(); } handleInputQueue(queueName?: string) { if (!this.runsOnInputQueue) return; if (!this.active) return; if (!this.shouldRunForInputQueue(queueName)) return; void this.runNow(); } handleUserEvent(event: ServerScriptUserTriggerEvent, user: MultiplayerUser) { if (!this.runsOnUserEvent) return; if (!this.active) return; if (!this.shouldRunForUserEvent(event)) return; this.pendingTriggers.push({ type: "user", event, user }); void this.runNow(); } private async runNow() { if (this.isRunning) { // Queue up another tick once the current one finishes. this.pendingRun = true; return; } await this.ensureVm(); if (this.disposed || !this.active) return; void this.tick(); } private shouldRunForPatches(patches: ExtendedPatch[]) { if (!this.runsOnStateChange) return false; if (patches.length === 0) return false; if (this.runOnAnyStateChange) return true; if (!this.stateChangeMatchers.length) { return false; } for (const patch of patches) { const patchSegments = toPatchPathSegments(patch); for (const matcher of this.stateChangeMatchers) { if (doesPathMatch(patchSegments, matcher)) { return true; } } } return false; } private shouldRunForInputQueue(queueName?: string) { if (!this.runsOnInputQueue) return false; if (this.runOnAnyInputQueue) return true; if (!queueName) return false; return this.inputQueueMatchers.includes(queueName); } private shouldRunForUserEvent(event: ServerScriptUserTriggerEvent) { if (!this.runsOnUserEvent) return false; if (this.runOnAnyUserEvent) return true; return this.userEventMatchers.includes(event); } private shouldRunForMigration(fromVersion: number): boolean { if (!this.runsOnSchemaMigration) return false; if (this.runOnAnyMigration) return true; return this.migrationVersionMatchers.includes(fromVersion); } /** * Executes migration script and returns the migrated state. * Returns undefined if the script doesn't handle this migration or returns no state. * Unlike other triggers, this runs synchronously (blocking) during schema migration. */ async runMigration( fromVersion: number, toVersion: number, state: any ): Promise { if (!this.runsOnSchemaMigration) return undefined; if (!this.shouldRunForMigration(fromVersion)) return undefined; await this.ensureVm(); if (!this.vm) return undefined; const now = this.environment.clock(); this.invocation += 1; const context: ExecutionContext = { state, now, scriptId: this.definition.id, origin: this.environment.label, invocation: this.invocation, trigger: { type: "schemaMigration", fromVersion, toVersion }, environment: { target: this.environment.target, mode: this.environment.mode, label: this.environment.label, }, }; try { const contextJson = JSON.stringify(context); const source = `(() => { const ctx = JSON.parse(${JSON.stringify(contextJson)}); ctx.getSharedConnectionData = globalThis.__noyaGetSharedConnectionDataProxy; ctx.setSharedConnectionData = globalThis.__noyaSetSharedConnectionData; ctx.dequeueInput = globalThis.__noyaDrainInputQueueProxy; return globalThis.__noyaLoop(ctx); })()`; let resultValue: any = undefined; this.vm.unwrapResult( this.vm.evalCode(source, `${this.definition.id}-migration.js`, { type: "global", }) ).consume((result) => { resultValue = this.vm!.dump(result); }); return this.normalizeResult(resultValue); } catch (error) { this.log("error", `Migration script error:`, error); return undefined; } } } export class ServerScriptManager< TModule extends QuickJSModuleLike = QuickJSModuleLike, > { private isHot = false; private runners = new Map>(); private quickJSPromise: Promise | null = null; private randomUUID: () => string; private environment: ResolvedServerScriptEnvironment; private log: (entry: ServerScriptLogEvent) => void; constructor(private options: ServerScriptManagerOptions) { this.randomUUID = options.randomUUID ?? getDefaultRandomUUID(); this.environment = resolveEnvironment(options.environment); this.log = options.log; } private getQuickJSPromise() { if (!this.quickJSPromise) { const maybePromise = this.options.getQuickJSModule(); this.quickJSPromise = Promise.resolve(maybePromise); } return this.quickJSPromise; } setIsHot(isHot: boolean) { if (this.isHot === isHot) return; this.isHot = isHot; for (const runner of this.runners.values()) { runner.setActive(isHot); } } syncInvocation( scriptId: string | undefined, invocation: number, timestamp?: number ) { const key = scriptId ?? (this.runners.size === 1 ? Array.from(this.runners.keys())[0] : undefined); if (!key) return; const runner = this.runners.get(key); runner?.syncInvocation(invocation, timestamp); } async updateSchema(schema?: any | null) { const definitions = getServerScriptDefinitions(schema); const seen = new Set(); for (const definition of definitions) { seen.add(definition.id); const existing = this.runners.get(definition.id); if (existing) { if (!existing.isSameDefinition(definition)) { existing.updateDefinition(definition); } if (this.isHot) { existing.setActive(true); } continue; } const runner = new ServerScriptRunner( definition, this.getQuickJSPromise(), this.options, this.environment, this.log, this.randomUUID ); this.runners.set(definition.id, runner); if (this.isHot) { runner.setActive(true); } } for (const [id, runner] of Array.from(this.runners.entries())) { if (seen.has(id)) continue; runner.dispose(); this.runners.delete(id); } } handleStateChange(patches: ExtendedPatch[]) { if (!this.isHot) return; if (patches.length === 0) return; for (const runner of this.runners.values()) { runner.handleStateChange(patches); } } handleInputQueueActivity(queueName?: string) { if (!this.isHot) return; for (const runner of this.runners.values()) { runner.handleInputQueue(queueName); } } handleUserEvent(event: ServerScriptUserTriggerEvent, user: MultiplayerUser) { if (!this.isHot) return; for (const runner of this.runners.values()) { runner.handleUserEvent(event, user); } } /** * Runs migration scripts in definition order. * If multiple scripts match, they chain: each receives the output of the previous. * Returns the final migrated state, or undefined if no scripts ran/returned state. * * Note: Unlike other handlers, this does NOT require isHot to be true since * migrations run during schema initialization before normal script execution. */ async handleSchemaMigration( fromVersion: number, toVersion: number, state: any ): Promise { let currentState = state; let anyScriptRan = false; for (const runner of this.runners.values()) { try { const result = await runner.runMigration( fromVersion, toVersion, currentState ); if (result !== undefined) { currentState = result; anyScriptRan = true; } } catch (error) { // Log error but continue with other scripts console.error(`Migration script ${runner.id} failed:`, error); } } return anyScriptRan ? currentState : undefined; } dispose() { for (const runner of this.runners.values()) { runner.dispose(); } this.runners.clear(); this.quickJSPromise = null; } }