@rivetkit/core
Version:
323 lines (291 loc) • 8.57 kB
text/typescript
import type { Next } from "hono";
import type { ContentfulStatusCode } from "hono/utils/http-status";
import * as errors from "@/actor/errors";
import { getEnvUniversal } from "@/utils";
import type { Logger } from "./log";
export function assertUnreachable(x: never): never {
throw new Error(`Unreachable case: ${x}`);
}
/**
* Safely stringifies an object, ensuring that the stringified object is under a certain size.
* @param obj any object to stringify
* @param maxSize maximum size of the stringified object in bytes
* @returns stringified object
*/
export function safeStringify(obj: unknown, maxSize: number) {
let size = 0;
function replacer(key: string, value: unknown) {
if (value === null || value === undefined) return value;
const valueSize =
typeof value === "string" ? value.length : JSON.stringify(value).length;
size += key.length + valueSize;
if (size > maxSize) {
throw new Error(`JSON object exceeds size limit of ${maxSize} bytes.`);
}
return value;
}
return JSON.stringify(obj, replacer);
}
// TODO: Instead of doing this, use a temp var for state and attempt to write
// it. Roll back state if fails to serialize.
/**
* Check if a value is CBOR serializable.
* Optionally pass an onInvalid callback to receive the path to invalid values.
*
* For a complete list of supported CBOR tags, see:
* https://github.com/kriszyp/cbor-x/blob/cc1cf9df8ba72288c7842af1dd374d73e34cdbc1/README.md#list-of-supported-tags-for-decoding
*/
export function isCborSerializable(
value: unknown,
onInvalid?: (path: string) => void,
currentPath = "",
): boolean {
// Handle primitive types directly
if (value === null || value === undefined) {
return true;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) {
onInvalid?.(currentPath);
return false;
}
return true;
}
if (typeof value === "boolean" || typeof value === "string") {
return true;
}
// Handle BigInt (CBOR tags 2 and 3)
if (typeof value === "bigint") {
return true;
}
// Handle Date objects (CBOR tags 0 and 1)
if (value instanceof Date) {
return true;
}
// Handle typed arrays (CBOR tags 64-82)
if (
value instanceof Uint8Array ||
value instanceof Uint8ClampedArray ||
value instanceof Uint16Array ||
value instanceof Uint32Array ||
value instanceof BigUint64Array ||
value instanceof Int8Array ||
value instanceof Int16Array ||
value instanceof Int32Array ||
value instanceof BigInt64Array ||
value instanceof Float32Array ||
value instanceof Float64Array
) {
return true;
}
// Handle Map (CBOR tag 259)
if (value instanceof Map) {
for (const [key, val] of value.entries()) {
const keyPath = currentPath
? `${currentPath}.key(${String(key)})`
: `key(${String(key)})`;
const valPath = currentPath
? `${currentPath}.value(${String(key)})`
: `value(${String(key)})`;
if (
!isCborSerializable(key, onInvalid, keyPath) ||
!isCborSerializable(val, onInvalid, valPath)
) {
return false;
}
}
return true;
}
// Handle Set (CBOR tag 258)
if (value instanceof Set) {
let index = 0;
for (const item of value.values()) {
const itemPath = currentPath
? `${currentPath}.set[${index}]`
: `set[${index}]`;
if (!isCborSerializable(item, onInvalid, itemPath)) {
return false;
}
index++;
}
return true;
}
// Handle RegExp (CBOR tag 27)
if (value instanceof RegExp) {
return true;
}
// Handle Error objects (CBOR tag 27)
if (value instanceof Error) {
return true;
}
// Handle arrays
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const itemPath = currentPath ? `${currentPath}[${i}]` : `[${i}]`;
if (!isCborSerializable(value[i], onInvalid, itemPath)) {
return false;
}
}
return true;
}
// Handle plain objects and records (CBOR tags 105, 51, 57344-57599)
if (typeof value === "object") {
// Allow plain objects and objects with prototypes (for records and named objects)
const proto = Object.getPrototypeOf(value);
if (proto !== null && proto !== Object.prototype) {
// Check if it's a known serializable object type
const protoConstructor = value.constructor;
if (protoConstructor && typeof protoConstructor.name === "string") {
// Allow objects with named constructors (records, named objects)
// This includes user-defined classes and built-in objects
// that CBOR can serialize with tag 27 or record tags
}
}
// Check all properties recursively
for (const key in value) {
const propPath = currentPath ? `${currentPath}.${key}` : key;
if (
!isCborSerializable(
value[key as keyof typeof value],
onInvalid,
propPath,
)
) {
return false;
}
}
return true;
}
// Not serializable
onInvalid?.(currentPath);
return false;
}
export interface DeconstructedError {
__type: "ActorError";
statusCode: ContentfulStatusCode;
public: boolean;
code: string;
message: string;
metadata?: unknown;
}
/** Deconstructs error in to components that are used to build responses. */
export function deconstructError(
error: unknown,
logger: Logger,
extraLog: Record<string, unknown>,
exposeInternalError = false,
): DeconstructedError {
// Build response error information. Only return errors if flagged as public in order to prevent leaking internal behavior.
//
// We log the error here instead of after generating the code & message because we need to log the original error, not the masked internal error.
let statusCode: ContentfulStatusCode;
let public_: boolean;
let code: string;
let message: string;
let metadata: unknown;
if (errors.ActorError.isActorError(error) && error.public) {
// Check if error has statusCode (could be ActorError instance or DeconstructedError)
statusCode = (
"statusCode" in error && error.statusCode ? error.statusCode : 400
) as ContentfulStatusCode;
public_ = true;
code = error.code;
message = getErrorMessage(error);
metadata = error.metadata;
logger.info("public error", {
code,
message,
issues: "https://github.com/rivet-gg/rivetkit/issues",
support: "https://rivet.gg/discord",
...extraLog,
});
} else if (exposeInternalError) {
if (errors.ActorError.isActorError(error)) {
statusCode = 500;
public_ = false;
code = error.code;
message = getErrorMessage(error);
metadata = error.metadata;
logger.info("internal error", {
code,
message,
issues: "https://github.com/rivet-gg/rivetkit/issues",
support: "https://rivet.gg/discord",
...extraLog,
});
} else {
statusCode = 500;
public_ = false;
code = errors.INTERNAL_ERROR_CODE;
message = getErrorMessage(error);
logger.info("internal error", {
code,
message,
issues: "https://github.com/rivet-gg/rivetkit/issues",
support: "https://rivet.gg/discord",
...extraLog,
});
}
} else {
statusCode = 500;
public_ = false;
code = errors.INTERNAL_ERROR_CODE;
message = errors.INTERNAL_ERROR_DESCRIPTION;
metadata = {
//url: `https://hub.rivet.gg/projects/${actorMetadata.project.slug}/environments/${actorMetadata.environment.slug}/actors?actorId=${actorMetadata.actor.id}`,
} satisfies errors.InternalErrorMetadata;
logger.warn("internal error", {
error: getErrorMessage(error),
stack: (error as Error)?.stack,
issues: "https://github.com/rivet-gg/rivetkit/issues",
support: "https://rivet.gg/discord",
...extraLog,
});
}
return {
__type: "ActorError",
statusCode,
public: public_,
code,
message,
metadata,
};
}
export function stringifyError(error: unknown): string {
if (error instanceof Error) {
if (
typeof process !== "undefined" &&
getEnvUniversal("_RIVETKIT_ERROR_STACK") === "1"
) {
return `${error.name}: ${error.message}${error.stack ? `\n${error.stack}` : ""}`;
} else {
return `${error.name}: ${error.message}`;
}
} else if (typeof error === "string") {
return error;
} else if (typeof error === "object" && error !== null) {
try {
return `${JSON.stringify(error)}`;
} catch {
return "[cannot stringify error]";
}
} else {
return `Unknown error: ${getErrorMessage(error)}`;
}
}
function getErrorMessage(err: unknown): string {
if (
err &&
typeof err === "object" &&
"message" in err &&
typeof err.message === "string"
) {
return err.message;
} else {
return String(err);
}
}
/** Generates a `Next` handler to pass to middleware in order to be able to call arbitrary middleware. */
export function noopNext(): Next {
return async () => {};
}