import { isQuery } from "../helpers.js" import { checkTextFormat, checkNumberFormat } from "../constants.js" import { formatLabel } from "../arguments/helpers.js" import { MetadataError, NumberFormat, TextFormat, VisitorDataType, } from "../arguments/types.js" import type { ResultNode, ResolvedNode, MethodMeta, ServiceMeta, MethodResult, } from "./types.js" import { sha256 } from "@noble/hashes/sha2.js" import { IDL } from "@icp-sdk/core/candid" import { DisplayCodecVisitor, uint8ArrayToHex, hexToUint8Array, } from "@ic-reactor/core" import type { ActorMethodReturnType, BaseActor, FunctionName, FunctionType, } from "@ic-reactor/core" export * from "./types.js" // ════════════════════════════════════════════════════════════════════════════ // Node Factory - Eliminates Boilerplate // ════════════════════════════════════════════════════════════════════════════ type Codec = { decode: (v: unknown) => unknown } /** * Creates a primitive node with automatic resolve implementation. */ function primitiveNode( type: T, label: string, candidType: string, displayType: ResultNode["displayType"], codec: Codec, extras: object = {} ): ResultNode { const node: ResultNode = { type, label, displayLabel: formatLabel(label), candidType, displayType, ...extras, resolve(data: unknown): ResolvedNode { try { return { ...node, value: codec.decode(data), raw: data, } as unknown as ResolvedNode } catch (e) { throw new MetadataError( `Failed to decode: ${e instanceof Error ? e.message : String(e)}`, label, candidType ) } }, } as unknown as ResultNode return node } // ════════════════════════════════════════════════════════════════════════════ // Simplified Result Field Visitor // ════════════════════════════════════════════════════════════════════════════ export class ResultFieldVisitor extends IDL.Visitor< string, ResultNode | MethodMeta | ServiceMeta > { private codec = new DisplayCodecVisitor() private recCache = new Map, ResultNode<"recursive">>() private getCodec(t: IDL.Type): Codec { const codec = t.accept(this.codec, null) as any return { decode: (v: unknown) => { try { return typeof codec?.decode === "function" ? codec.decode(v) : v } catch { return v } }, } } // ══════════════════════════════════════════════════════════════════════════ // Service & Function // ══════════════════════════════════════════════════════════════════════════ public visitService(t: IDL.ServiceClass): ServiceMeta { const result = {} as ServiceMeta for (const [name, func] of t._fields) { // Process each service method using dedicated method handler result[name as FunctionName] = this.visitFuncAsMethod( func, name as FunctionName ) } return result } /** * Handle func type when encountered as a service method definition. * Returns MethodMeta with information about the method's inputs/outputs. * This is public so callers can explicitly request method metadata. */ public visitFuncAsMethod( t: IDL.FuncClass, functionName: FunctionName ): MethodMeta { const functionType: FunctionType = isQuery(t) ? "query" : "update" const returns = t.retTypes.map((ret, i) => ret.accept(this, `__ret${i}`) ) as ResultNode[] return { functionType, functionName, returns, returnCount: t.retTypes.length, resolve: ( data: ActorMethodReturnType]> ): MethodResult => { const dataArray = returns.length <= 1 ? [data] : (data as unknown[]) return { functionType, functionName, results: returns.map((node, i) => node.resolve(dataArray[i])), raw: data, } }, } } /** * Handle func type when encountered as a data field (e.g., callback in a record). * Returns ResultNode that can resolve [Principal, string] data to a func reference. */ public visitFunc(_t: IDL.FuncClass, label: string): ResultNode<"func"> { const node: ResultNode<"func"> = { type: "func", label, displayLabel: formatLabel(label), candidType: "func", displayType: "func", canisterId: "", // placeholder, populated on resolve methodName: "", // placeholder, populated on resolve resolve(data: unknown): ResolvedNode<"func"> { // Func values are represented as [Principal, string] tuples if (!Array.isArray(data) || data.length !== 2) { throw new MetadataError( `Expected func reference [Principal, string], but got ${typeof data}`, label, "func" ) } const [principal, methodName] = data const canisterId = typeof principal === "string" ? principal : (principal?.toText?.() ?? String(principal)) return { ...node, canisterId, methodName: String(methodName), raw: data, } }, } return node } // ══════════════════════════════════════════════════════════════════════════ // Compound Types // ══════════════════════════════════════════════════════════════════════════ public visitRecord( _t: IDL.RecordClass, fields_: Array<[string, IDL.Type]>, label: string ): ResultNode<"record"> | ResultNode<"funcRecord"> { const fields: Record = {} // Track func fields for funcRecord detection const funcEntries: Array<{ key: string funcType: IDL.FuncClass node: ResultNode<"func"> }> = [] for (const [key, type] of fields_) { const fieldNode = type.accept(this, key) as ResultNode fields[key] = fieldNode if (type instanceof IDL.FuncClass) { funcEntries.push({ key, funcType: type, node: fieldNode as ResultNode<"func">, }) } } // ── funcRecord: exactly one func field + other argument fields ── if (funcEntries.length === 1) { const { key: funcFieldKey, funcType, node: funcFieldNode, } = funcEntries[0] const funcCallType: "query" | "update" = isQuery(funcType) ? "query" : "update" const argFields: Record = {} for (const [k, v] of Object.entries(fields)) { if (k !== funcFieldKey) argFields[k] = v } const node: ResultNode<"funcRecord"> = { type: "funcRecord", label, displayLabel: formatLabel(label), candidType: "record", displayType: "func-record", canisterId: "", methodName: "", funcType: funcCallType, funcClass: funcType, funcFieldKey, funcField: funcFieldNode, argFields, fields, resolve(data: unknown): ResolvedNode<"funcRecord"> { if (data === null || data === undefined) { throw new MetadataError( `Expected funcRecord, but got ${data === null ? "null" : "undefined"}`, label, "record" ) } const recordData = data as Record const resolvedFields: Record = {} let index = 0 for (const [key, field] of Object.entries(fields)) { const value = recordData[key] !== undefined ? recordData[key] : recordData[index] resolvedFields[key] = field.resolve(value) index++ } const resolvedFuncField = resolvedFields[ funcFieldKey ] as ResolvedNode<"func"> const resolvedArgFields: Record = {} for (const [k, v] of Object.entries(resolvedFields)) { if (k !== funcFieldKey) resolvedArgFields[k] = v } // Build display-type default args ready for callMethod const argRecord = Object.fromEntries( Object.entries(resolvedArgFields).map(([k, v]) => [ k, v.value ?? v.raw, ]) ) const defaultArgs = funcType.argTypes.length > 0 ? [argRecord] : [] return { ...node, canisterId: resolvedFuncField.canisterId, methodName: resolvedFuncField.methodName, funcField: resolvedFuncField, argFields: resolvedArgFields, fields: resolvedFields, defaultArgs, raw: data, } }, } return node } // ── Regular record ── const node: ResultNode<"record"> = { type: "record", label, displayLabel: formatLabel(label), candidType: "record", displayType: "object", fields, resolve(data: unknown): ResolvedNode<"record"> { if (data === null || data === undefined) { throw new MetadataError( `Expected record, but got ${data === null ? "null" : "undefined"}`, label, "record" ) } const recordData = data as Record const resolvedFields: Record = {} let index = 0 for (const [key, field] of Object.entries(fields)) { // Try named key first, then try numeric index (for tuples/indexed records) const value = recordData[key] !== undefined ? recordData[key] : recordData[index] if (!field || typeof field.resolve !== "function") { throw new MetadataError( `Field "${key}" is not a valid ResultNode`, `${label}.${key}`, "record" ) } resolvedFields[key] = field.resolve(value) index++ } return { ...node, fields: resolvedFields, raw: data } }, } return node } public visitVariant( _t: IDL.VariantClass, fields_: Array<[string, IDL.Type]>, label: string ): ResultNode<"variant"> { const options: Record = {} for (const [key, type] of fields_) { options[key] = type.accept(this, key) as ResultNode } const isResult = ("Ok" in options && "Err" in options) || ("ok" in options && "err" in options) const isNullVariant = !isResult && Object.values(options).every((option) => option.type === "null") const node: ResultNode<"variant"> = { type: "variant", label, displayLabel: formatLabel(label), candidType: "variant", displayType: isResult ? "result" : isNullVariant ? "variant-null" : "variant", options, selectedValue: {} as ResultNode, // placeholder, populated on resolve resolve(data: unknown): ResolvedNode<"variant"> { if (data === null || data === undefined) { throw new MetadataError( `Expected variant, but got ${data === null ? "null" : "undefined"}, raw: ${data}`, label, "variant" ) } const variantData = data as Record // Support both raw { Selected: value } and transformed { _type: 'Selected', Selected: value } const selected = (variantData._type as string) || Object.keys(variantData)[0] const optionNode = options[selected] if (!optionNode) { throw new MetadataError( `Option "${selected}" not found. Available: ${Object.keys(options).join(", ")}`, label, "variant" ) } return { ...node, selected, selectedValue: optionNode.resolve(variantData[selected]), raw: data, } }, } return node } public visitTuple( _t: IDL.TupleClass, components: IDL.Type[], label: string ): ResultNode<"tuple"> { const items = components.map( (t, i) => t.accept(this, `_${i}`) as ResultNode ) const node: ResultNode<"tuple"> = { type: "tuple", label, displayLabel: formatLabel(label), candidType: "tuple", displayType: "array", items, resolve(data: unknown): ResolvedNode<"tuple"> { if (data === null || data === undefined || !Array.isArray(data)) { throw new MetadataError( `Expected tuple, but got ${data === null ? "null" : typeof data}, raw: ${data}`, label, "tuple" ) } const tupleData = data as unknown[] return { ...node, items: items.map((item, i) => item.resolve(tupleData[i])), raw: data, } }, } return node } public visitOpt( _t: IDL.OptClass, ty: IDL.Type, label: string ): ResultNode<"optional"> { const inner = ty.accept(this, label) as ResultNode const node: ResultNode<"optional"> = { type: "optional", label, displayLabel: formatLabel(label), candidType: "opt", displayType: "nullable", value: null, // null until resolved resolve(data: unknown): ResolvedNode<"optional"> { // If data is an array (raw format [T] or []), unwrap it. // Otherwise, use data directly (already transformed or null/undefined). const resolved = Array.isArray(data) ? data.length > 0 ? inner.resolve(data[0]) : null : data !== null && data !== undefined ? inner.resolve(data) : null return { ...node, value: resolved, raw: data } }, } return node } public visitVec( _t: IDL.VecClass, ty: IDL.Type, label: string ): ResultNode<"vector"> | ResultNode<"blob"> { // Blob detection (vec nat8) if (ty instanceof IDL.FixedNatClass && ty._bits === 8) { const codec = this.getCodec(_t) const node: ResultNode<"blob"> = { type: "blob", label, displayLabel: formatLabel(label), candidType: "blob", displayType: "string", length: 0, hash: "", value: "", // empty schema placeholder, populated on resolve resolve(data: unknown): ResolvedNode<"blob"> { // The display codec renders every blob as a hex string regardless // of size, so `displayType` stays the schema's "string" and // `length` can finally be what its type declares: bytes, not hex // characters. // // `data` is raw candid bytes on the normal reactor flow, but // visitOpt hands already-transformed inner values through, so a // hex string is a supported shape here. It must not fall into a // `new Uint8Array(...)` coercion: a string (or null) taken through // the TypedArray length constructor yields ZERO bytes, fabricating // `length: 0` and a sha256 of the empty payload for a real blob. const bytes = data instanceof Uint8Array ? data : Array.isArray(data) ? new Uint8Array(data) : typeof data === "string" ? hexToUint8Array(data) : null if (bytes === null) { throw new MetadataError( `Expected blob bytes or hex string, but got ${data === null ? "null" : typeof data}`, label, "blob" ) } const value = typeof data === "string" ? data : (codec.decode(data) as string) return { ...node, value, hash: uint8ArrayToHex(sha256(bytes)), length: bytes.length, raw: data, } }, } return node } const itemSchema = ty.accept(this, "item") as ResultNode const node: ResultNode<"vector"> = { type: "vector", label, displayLabel: formatLabel(label), candidType: "vec", displayType: "array", items: [], // empty schema placeholder, populated on resolve resolve(data: unknown): ResolvedNode<"vector"> { if (data === null || data === undefined || !Array.isArray(data)) { throw new MetadataError( `Expected vector, but got ${data === null ? "null" : typeof data}, raw: ${data}`, label, "vec" ) } const vectorData = data as unknown[] return { ...node, items: vectorData.map((v) => itemSchema.resolve(v)), raw: data, } }, } return node } public visitRec( t: IDL.RecClass, ty: IDL.ConstructType, label: string ): ResultNode<"recursive"> { if (this.recCache.has(t)) { return this.recCache.get(t)! as ResultNode<"recursive"> } const self = this // Lazy extraction to prevent infinite loops let innerSchema: ResultNode | null = null const getInner = () => (innerSchema ??= ty.accept(self, label) as ResultNode) const node: ResultNode<"recursive"> = { type: "recursive", label, displayLabel: formatLabel(label), candidType: "rec", displayType: "recursive", inner: {} as ResultNode, // placeholder, populated on resolve resolve(data: unknown): ResolvedNode<"recursive"> { return { ...node, inner: getInner().resolve(data), raw: data } }, } this.recCache.set(t, node) return node } // ══════════════════════════════════════════════════════════════════════════ // Primitives - Using Factory // ══════════════════════════════════════════════════════════════════════════ public visitPrincipal( t: IDL.PrincipalClass, label: string ): ResultNode<"principal"> { return primitiveNode( "principal", label, "principal", "string", this.getCodec(t), { format: checkTextFormat(label) as TextFormat, } ) } public visitText(t: IDL.TextClass, label: string): ResultNode<"text"> { return primitiveNode("text", label, "text", "string", this.getCodec(t), { format: checkTextFormat(label) as TextFormat, }) } public visitBool(t: IDL.BoolClass, label: string): ResultNode<"boolean"> { return primitiveNode("boolean", label, "bool", "boolean", this.getCodec(t)) } public visitNull(t: IDL.NullClass, label: string): ResultNode<"null"> { return primitiveNode("null", label, "null", "null", this.getCodec(t)) } public visitInt(t: IDL.IntClass, label: string): ResultNode<"number"> { return primitiveNode("number", label, "int", "string", this.getCodec(t), { format: checkNumberFormat(label) as NumberFormat, }) } public visitNat(t: IDL.NatClass, label: string): ResultNode<"number"> { return primitiveNode("number", label, "nat", "string", this.getCodec(t), { format: checkNumberFormat(label) as NumberFormat, }) } public visitFloat(t: IDL.FloatClass, label: string): ResultNode<"number"> { return primitiveNode( "number", label, `float${t._bits}`, "number", this.getCodec(t), { format: checkNumberFormat(label) as NumberFormat, } ) } public visitFixedInt( t: IDL.FixedIntClass, label: string ): ResultNode<"number"> { const bits = t._bits return primitiveNode( "number", label, `int${bits}`, bits <= 32 ? "number" : "string", this.getCodec(t), { format: checkNumberFormat(label) as NumberFormat, } ) } public visitFixedNat( t: IDL.FixedNatClass, label: string ): ResultNode<"number"> { const bits = t._bits return primitiveNode( "number", label, `nat${bits}`, bits <= 32 ? "number" : "string", this.getCodec(t), { format: checkNumberFormat(label) as NumberFormat, } ) } public visitType(_t: IDL.Type, label: string): ResultNode<"unknown"> { return primitiveNode("unknown", label, "unknown", "unknown", { decode: (v) => v, }) } }