import type { Hook } from "./types.ts"; import { EventEmitter } from "./event.ts"; import type { SandboxFactory } from "./sandboxes/sandbox.ts"; import { Plug } from "./plug.ts"; import { InMemoryManifestCache, type ManifestCache } from "./manifest_cache.ts"; import { builtinPlugNames, builtinPlugPaths, } from "../../plugs/builtin_plugs.ts"; import type { LuaFunctionDocumentation } from "../../plug-api/types/index.ts"; export interface SysCallMapping { [key: string]: SyscallSignature | SyscallDefinition; } export type SystemEvents = { plugLoaded: (plug: Plug) => void | Promise; plugUnloaded: (name: string) => void | Promise; }; // Passed to every syscall, allows to pass in additional context that the syscall may use export type SyscallContext = { // This is the plug that is invoking the syscall, // which may be undefined where this cannot be determined (e.g. when running in a NoSandbox) plug?: string; }; type SyscallSignature = (...args: any[]) => Promise | any; export type SyscallDefinition = { callback: SyscallSignature; } & LuaFunctionDocumentation; type RegisteredSyscall = { requiredPermissions: string[]; callback: SyscallSignature; } & LuaFunctionDocumentation; export type SystemOptions = { manifestCache?: ManifestCache; plugFlushTimeout?: number; }; export class System extends EventEmitter> { registeredSyscalls = new Map(); protected plugs = new Map>(); protected enabledHooks = new Set>(); /** * @param env either an environment or undefined for hybrid mode */ constructor( readonly env: string | undefined = undefined, readonly options: SystemOptions = {}, ) { super(); if (!options.manifestCache) { options.manifestCache = new InMemoryManifestCache(); } } get loadedPlugs(): Map> { return this.plugs; } addHook(feature: Hook) { this.enabledHooks.add(feature); feature.apply(this); } registerSyscalls( requiredCapabilities: string[], ...registrationObjects: SysCallMapping[] ) { for (const registrationObject of registrationObjects) { for (const [name, registration] of Object.entries(registrationObject)) { const definition = typeof registration === "function" ? { callback: registration } : registration; const cleanName = name.startsWith("lua:") ? name.slice("lua:".length) : name; const namespace = cleanName.split(".")[0]; const { callback, ...metadata } = definition; this.registeredSyscalls.set(name, { requiredPermissions: requiredCapabilities, callback, see: `API/${namespace}`, ...metadata, }); } } } /** * Invokes a function named using the "plug.functionName" pattern, for convenience * @param name name of the function (e.g. plug.doSomething) * @param args an array of arguments to pass to the function */ invokeFunction(name: string, args: any[]): Promise { // Some sanity type checks if (typeof name !== "string") { throw new Error( `invokeFunction: function name should be a string, got ${typeof name}`, ); } if (!Array.isArray(args)) { throw new Error( `invokeFunction: args should be an array, got ${typeof args}`, ); } const [plugName, functionName] = name.split("."); if (!functionName) { // Sanity check throw new Error(`Missing function name: ${name}`); } const plug = this.loadedPlugs.get(plugName); if (!plug) { throw new Error(`Plug ${plugName} not found invoking ${name}`); } return plug.invoke(functionName, args); } localSyscall(name: string, args: any[]): Promise { return this.syscall({}, name, args); } syscall(ctx: SyscallContext, name: string, args: any[]): Promise { const syscall = this.registeredSyscalls.get(name); if (!syscall) { throw Error(`Unregistered syscall ${name}`); } if (ctx.plug) { // Only when running in a plug context do we check permissions const plug = this.loadedPlugs.get(ctx.plug); if (!plug) { throw new Error( `Plug ${ctx.plug} not found while attempting to invoke ${name}}`, ); } for (const permission of syscall.requiredPermissions) { if (!plug.grantedPermissions.includes(permission)) { throw Error(`Missing permission '${permission}' for syscall ${name}`); } } } return Promise.resolve(syscall.callback(ctx, ...args)); } /** * @param path The plug file's space path; also used as the manifest cache key. * @param cacheHash Used to determine if the manifest is up to date. Should be `lastModified` */ async loadPlug( sandboxFactory: SandboxFactory, path: string, cacheHash: number = -1, ): Promise> { const plug = await Plug.createLazily(this, path, cacheHash, sandboxFactory); const manifest = plug.manifest; if ( !builtinPlugPaths.includes(path) && builtinPlugNames.includes(manifest.name) ) { plug.stop(); throw new Error("Plug tried to overwrite internal plug"); } // Validate the manifest let errors: string[] = []; for (const feature of this.enabledHooks) { errors = [...errors, ...feature.validateManifest(manifest)]; } if (errors.length > 0) { throw new Error(`Invalid manifest: ${errors.join(", ")}`); } if (this.plugs.has(manifest.name)) { this.unload(manifest.name); } console.log("Activated plug", manifest.name); this.plugs.set(manifest.name, plug); await this.emit("plugLoaded", plug); return plug; } unloadByPath(path: string): boolean { for (const [name, plug] of this.plugs) { if (plug.path === path) { this.unload(name); return true; } } return false; } unload(name: string) { const plug = this.plugs.get(name); if (!plug) { return; } void plug.stop(); void this.emit("plugUnloaded", name); this.plugs.delete(name); } async unloadAll(): Promise { await Promise.all( Array.from(this.plugs.keys()).map(this.unload.bind(this)), ); } }