import * as fs from "node:fs"; import * as path from "node:path"; import { isLegacyProject, rootPaths } from "@nodecg/internal-util"; import $RefParser from "@nodecg/json-schema-lib"; import hasha from "hasha"; import { klona as clone } from "klona/json"; import { AbstractReplicant, ignoreProxy, proxyRecursive, type ReplicantValue, resumeProxy, } from "../../shared/replicants.shared"; import { getSchemaDefault } from "../../shared/utils/compileJsonSchema"; import type { NodeCG } from "../../types/nodecg"; import { createLogger } from "../logger"; import { formatSchema } from "./schema-hacks"; /** * Never instantiate this directly. * Always use Replicator.declare instead. * The Replicator needs to have complete control over the ServerReplicant class. */ export class ServerReplicant< V, O extends NodeCG.Replicant.Options = NodeCG.Replicant.Options, > extends AbstractReplicant<"server", V, O> { constructor( name: string, namespace: string, opts: O = {} as Record, startingValue: V | undefined = undefined, ) { super(name, namespace, opts); /** * Server Replicants are immediately considered declared. * Client Replicants aren't considered declared until they have * fetched the current value from the server, which is an * async operation that takes time. */ this.status = "declared"; this.log = createLogger(`Replicant/${namespace}.${name}`); function getBundlePath() { const rootPath = rootPaths.runtimeRootPath; if (isLegacyProject()) { return path.join(rootPaths.getRuntimeRoot(), "bundles", namespace); } const rootPackageJson = fs.readFileSync( path.join(rootPath, "package.json"), "utf-8", ); if (JSON.parse(rootPackageJson).name === namespace) { return rootPath; } else { const bundlesDir = path.join(rootPath, "bundles"); const bundlesDirStat = fs.existsSync(bundlesDir) ? fs.statSync(bundlesDir) : null; if (bundlesDirStat?.isDirectory()) { const bundles = fs.readdirSync(path.join(rootPath, "bundles"), { withFileTypes: true, }); for (const bundleDir of bundles) { if (!bundleDir.isDirectory()) { continue; } const bundlePath = path.join(rootPath, "bundles", bundleDir.name); const bundlePackageJsonPath = path.join(bundlePath, "package.json"); if (!fs.existsSync(bundlePackageJsonPath)) { continue; } const bundlePackageJson = fs.readFileSync( bundlePackageJsonPath, "utf-8", ); if (JSON.parse(bundlePackageJson).name === namespace) { return bundlePath; } } } return false; } } let absoluteSchemaPath: string | undefined; const schemaPath = opts.schemaPath; if (schemaPath) { if (path.isAbsolute(schemaPath)) { absoluteSchemaPath = schemaPath; } else { absoluteSchemaPath = path.join(rootPaths.getRuntimeRoot(), schemaPath); } } else { const bundlePath = getBundlePath(); if (bundlePath) { absoluteSchemaPath = path.join( bundlePath, "schemas", `${encodeURIComponent(name)}.json`, ); } } if (absoluteSchemaPath && fs.existsSync(absoluteSchemaPath)) { try { const rawSchema = $RefParser.readSync(absoluteSchemaPath); const parsedSchema = formatSchema( rawSchema.root, rawSchema.rootFile, rawSchema.files, ); if (!parsedSchema) { throw new Error("parsed schema was unexpectedly undefined"); } this.schema = parsedSchema; this.schemaSum = hasha(JSON.stringify(parsedSchema), { algorithm: "sha1", }); this.validate = this._generateValidator(); } catch (e: any) { if (!process.env.NODECG_TEST) { this.log.error( "Schema could not be loaded, are you sure that it is valid JSON?\n", e.stack, ); } } } let defaultValue = "defaultValue" in opts ? opts.defaultValue : undefined; // Set the default value, if a schema is present and no default value was provided. if (this.schema && defaultValue === undefined) { defaultValue = getSchemaDefault( this.schema, `${this.namespace}:${this.name}`, ) as V; } // If `opts.persistent` is true and this replicant has a persisted value, try to load that persisted value. // Else, apply `defaultValue`. if ( opts.persistent && typeof startingValue !== "undefined" && startingValue !== null ) { if (this.validate(startingValue, { throwOnInvalid: false })) { this._value = proxyRecursive(this, startingValue, "/") as any; this.log.replicants("Loaded a persisted value:", startingValue); } else if (this.schema) { this._value = proxyRecursive( this, getSchemaDefault(this.schema, `${this.namespace}:${this.name}`), "/", ) as any; this.log.replicants( "Discarded persisted value, as it failed schema validation. Replaced with defaults from schema.", ); } } else { if (this.schema && defaultValue !== undefined) { this.validate(defaultValue); } if (defaultValue === undefined) { this.log.replicants( 'Declared "%s" in namespace "%s"\n', name, namespace, ); } else { this._value = proxyRecursive(this, clone(defaultValue), "/") as any; this.log.replicants( 'Declared "%s" in namespace "%s" with defaultValue:\n', name, namespace, defaultValue, ); } } } get value(): ReplicantValue<"server", V, O> { return this._value as any; } set value(newValue) { if (newValue === this._value) { this.log.replicants("value unchanged, no action will be taken"); return; } this.validate(newValue); this.log.replicants("running setter with", newValue); const clonedNewVal = clone(newValue); this._addOperation({ path: "/", method: "overwrite", args: { newValue: clonedNewVal, }, }); ignoreProxy(this); this._value = proxyRecursive(this, newValue, "/"); resumeProxy(this); } /** * Refer to the abstract base class' implementation for details. * @private */ _addOperation( operation: NodeCG.Replicant.Operation>, ): void { this._operationQueue.push(operation); if (!this._pendingOperationFlush) { this._oldValue = clone(this.value); this._pendingOperationFlush = true; process.nextTick(() => { this._flushOperations(); }); } } /** * Refer to the abstract base class' implementation for details. * @private */ _flushOperations(): void { this._pendingOperationFlush = false; if (this._operationQueue.length <= 0) return; this.revision++; this.emit("operations", { name: this.name, namespace: this.namespace, operations: this._operationQueue, revision: this.revision, }); const opQ = this._operationQueue; this._operationQueue = []; this.emit("change", this.value, this._oldValue, opQ); } }