@nodecg/types
Version:
Dynamic broadcast graphics rendered in a browser
250 lines (231 loc) • 6.88 kB
text/typescript
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<V> = NodeCG.Replicant.Options<V>,
> extends AbstractReplicant<"server", V, O> {
constructor(
name: string,
namespace: string,
opts: O = {} as Record<any, unknown>,
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<ReplicantValue<"server", V, O>>,
): 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);
}
}