UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

655 lines (566 loc) 20.1 kB
import { $hook } from "../primitives/$hook.ts"; import { SchemaCodec } from "./SchemaCodec.ts"; import type { TArray, TObject, TSchema, TUnion } from "./TypeProvider.ts"; import { type Static, z } from "./TypeProvider.ts"; // ============================================================================= // Keyless JSON Codec // ============================================================================= // Schema-driven JSON encoding without keys: // - Schema defines field order → no keys needed in output // - Uses native JSON.stringify on arrays (fast!) // - Uses native JSON.parse for decoding (blazing fast) // - 50-56% smaller than JSON, decode 1.7-2x faster // // Example: // JSON: {"name":"Alice","age":30,"active":true} (39 bytes) // Keyless: ["Alice",30,true] (17 bytes) // ============================================================================= export interface KeylessCodec<T = any> { encode: (value: T) => string; decode: (str: string) => T; } export interface KeylessCodecOptions { /** * Whether to use `new Function()` for code compilation. * When false, uses an interpreter-based approach (safer but slower). * * @default Auto-detected: false in browser (CSP compatibility), true on server */ useFunctionCompilation?: boolean; } /** * KeylessJsonSchemaCodec provides schema-driven JSON encoding without keys. * * It uses the schema to determine field order, allowing the encoded output * to be a simple JSON array instead of an object with keys. */ export class KeylessJsonSchemaCodec extends SchemaCodec { protected readonly cache = new Map<TSchema, KeylessCodec>(); protected readonly textEncoder = new TextEncoder(); protected readonly textDecoder = new TextDecoder(); protected varCounter = 0; protected useFunctionCompilation = true; /** * Configure codec options. */ public configure(options: KeylessCodecOptions): this { if (options.useFunctionCompilation !== undefined) { this.useFunctionCompilation = options.useFunctionCompilation; this.cache.clear(); // Clear cache when compilation mode changes } return this; } /** * Hook to auto-detect safe mode on configure. * Disables function compilation in browser by default. */ protected onConfigure = $hook({ on: "configure", handler: () => { // Auto-detect: disable function compilation in browser (CSP compatibility) // and test if eval/Function is available this.useFunctionCompilation = this.canUseFunction(); }, }); /** * Encode value to a keyless JSON string. */ public encodeToString<T extends TSchema>( schema: T, value: Static<T>, ): string { return this.getCodec(schema).encode(value); } /** * Encode value to binary (UTF-8 encoded keyless JSON). */ public encodeToBinary<T extends TSchema>( schema: T, value: Static<T>, ): Uint8Array { return this.textEncoder.encode(this.encodeToString(schema, value)); } /** * Decode keyless JSON string or binary to value. */ public decode<T>(schema: TSchema, value: unknown): T { if (value instanceof Uint8Array) { const text = this.textDecoder.decode(value); return this.getCodec(schema).decode(text) as T; } if (typeof value === "string") { return this.getCodec(schema).decode(value) as T; } // If already an array (parsed JSON), reconstruct object if (Array.isArray(value)) { return this.reconstructObject(schema, value) as T; } return value as T; } /** * Test if `new Function()` is available (not blocked by CSP). */ protected canUseFunction(): boolean { try { const fn = new Function("return true"); return fn() === true; } catch { return false; } } // =========================================================================== // Codec Compilation // =========================================================================== /** * Get a compiled codec for the given schema. * Codecs are cached for reuse. */ protected getCodec<T>(schema: TSchema): KeylessCodec<T> { let c = this.cache.get(schema); if (!c) { c = this.useFunctionCompilation ? this.compileWithFunction(schema) : this.compileInterpreted(schema); this.cache.set(schema, c); } return c as KeylessCodec<T>; } protected nextVar(): string { return `_${this.varCounter++}`; } /** * Compile codec using `new Function()` for maximum performance. * Only used when CSP allows and useFunctionCompilation is true. */ protected compileWithFunction(schema: TSchema): KeylessCodec { this.varCounter = 0; const encBody = this.genEnc(schema, "v"); this.varCounter = 0; const decBody = this.genDec(schema); const encoder = new Function("v", `return JSON.stringify(${encBody});`) as ( value: any, ) => string; const decoder = new Function( "s", `const a=JSON.parse(s);let i=0;${decBody.code}return ${decBody.result};`, ) as (str: string) => any; return { encode: encoder, decode: decoder }; } /** * Compile codec using interpreter-based approach. * Safer (no eval/Function) but slower. Used in browser by default. */ protected compileInterpreted(schema: TSchema): KeylessCodec { const self = this; return { encode(value: any): string { return JSON.stringify(self.interpretEncode(schema, value)); }, decode(str: string): any { const ctx = { arr: JSON.parse(str), i: 0 }; return self.interpretDecode(schema, ctx); }, }; } // =========================================================================== // Interpreter-based Encoding (Safe Mode) // =========================================================================== protected interpretEncode(schema: TSchema, value: any): any { if (this.isLeaf(schema)) { return value; } if (z.schema.isBigInt(schema)) { return `${value}n`; } if (z.schema.isArray(schema)) { const arrSchema = schema as TArray; if (!Array.isArray(value)) return value; if (z.schema.isScalar(arrSchema.items)) { return value; } return value.map((e) => this.interpretEncode(arrSchema.items, e)); } if (z.schema.isObject(schema)) { const result: any[] = []; for (const { key, isOpt, isNullable, inner } of this.getObjectFields( schema as TObject, )) { const v = value[key]; if (isOpt) { result.push(v !== undefined ? this.interpretEncode(inner, v) : null); } else if (isNullable) { result.push(v !== null ? this.interpretEncode(inner, v) : null); } else { result.push(this.interpretEncode(inner, v)); } } return result; } if (z.schema.isOptional(schema) || z.schema.isUnion(schema)) { const inner = this.unwrap(schema); if (this.isNullable(schema)) { return value !== null ? this.interpretEncode(inner, value) : null; } return value !== undefined ? this.interpretEncode(inner, value) : null; } return value; } // =========================================================================== // Interpreter-based Decoding (Safe Mode) // =========================================================================== protected interpretDecode( schema: TSchema, ctx: { arr: any[]; i: number }, ): any { if (this.isLeaf(schema)) { return ctx.arr[ctx.i++]; } if (z.schema.isBigInt(schema)) { return BigInt(ctx.arr[ctx.i++].slice(0, -1)); } if (z.schema.isArray(schema)) { const arrSchema = schema as TArray; const arr = ctx.arr[ctx.i++]; if (!Array.isArray(arr)) return arr; if (z.schema.isObject(arrSchema.items)) { return arr.map((e) => this.interpretDecodeFromValue(arrSchema.items, e), ); } return arr; } if (z.schema.isObject(schema)) { const result: Record<string, any> = {}; for (const { key, isOpt, isNullable, inner } of this.getObjectFields( schema as TObject, )) { const val = ctx.arr[ctx.i++]; if (isOpt) { if (val !== null) { result[key] = this.interpretDecodeFromValue(inner, val); } } else if (isNullable) { result[key] = val === null ? null : this.interpretDecodeFromValue(inner, val); } else { result[key] = this.interpretDecodeFromValue(inner, val); } } return result; } if (z.schema.isOptional(schema) || z.schema.isUnion(schema)) { const inner = this.unwrap(schema); const val = ctx.arr[ctx.i++]; if (val === null) { return this.isNullable(schema) ? null : undefined; } if (z.schema.isObject(inner) || z.schema.isArray(inner)) { return this.interpretDecodeFromValue(inner, val); } return val; } return ctx.arr[ctx.i++]; } protected interpretDecodeFromValue(schema: TSchema, value: any): any { if (this.isLeaf(schema)) { return value; } if (z.schema.isBigInt(schema)) { return BigInt(value.slice(0, -1)); } if (z.schema.isArray(schema)) { if (!Array.isArray(value)) return value; const arrSchema = schema as TArray; if (z.schema.isObject(arrSchema.items)) { return value.map((e) => this.interpretDecodeFromValue(arrSchema.items, e), ); } return value; } if (z.schema.isObject(schema)) { const objSchema = schema as TObject; const props = objSchema.properties as Record<string, TSchema>; const keys = Object.keys(props); const result: Record<string, any> = {}; for (let idx = 0; idx < keys.length; idx++) { const k = keys[idx]; const prop = props[k]; const isOpt = z.schema.isOptional(prop); const inner = this.unwrap(prop); const v = value[idx]; if (isOpt && v === null) { // Optional fields encoded as null should be decoded as undefined (omitted) continue; } if (z.schema.isObject(inner)) { result[k] = this.interpretDecodeFromValue(inner, v); } else if (z.schema.isBigInt(inner)) { result[k] = BigInt(v.slice(0, -1)); } else { result[k] = v; } } return result; } return value; } // =========================================================================== // Encoder - generates code that returns an array representation // =========================================================================== protected genEnc(schema: TSchema, ve: string): string { if (this.isLeaf(schema)) { return ve; } if (z.schema.isBigInt(schema)) { return `${ve}+'n'`; } if (z.schema.isArray(schema)) { const arrSchema = schema as TArray; const itemEnc = this.genEnc(arrSchema.items, "e"); if (z.schema.isScalar(arrSchema.items)) { return ve; } return `${ve}.map(e=>${itemEnc})`; } if (z.schema.isObject(schema)) { const parts: string[] = []; for (const { key, isOpt, isNullable, inner } of this.getObjectFields( schema as TObject, )) { const sk = JSON.stringify(key); const access = `${ve}[${sk}]`; const innerEnc = this.genEnc(inner, access); if (isOpt) { parts.push(`${access}!==undefined?${innerEnc}:null`); } else if (isNullable) { parts.push(`${access}!==null?${innerEnc}:null`); } else { parts.push(innerEnc); } } return `[${parts.join(",")}]`; } if (z.schema.isOptional(schema) || z.schema.isUnion(schema)) { const inner = this.unwrap(schema); const innerEnc = this.genEnc(inner, ve); if (this.isNullable(schema)) { return `${ve}!==null?${innerEnc}:null`; } return `${ve}!==undefined?${innerEnc}:null`; } return ve; } // =========================================================================== // Decoder - generates code to reconstruct object from parsed array // =========================================================================== protected genDec(schema: TSchema): { code: string; result: string } { const v = this.nextVar(); if (this.isLeaf(schema)) { return { code: "", result: "a[i++]" }; } if (z.schema.isBigInt(schema)) { return { code: "", result: "BigInt(a[i++].slice(0,-1))" }; } if (z.schema.isArray(schema)) { const arrSchema = schema as TArray; // Check if array items need transformation (objects) if (z.schema.isObject(arrSchema.items)) { const itemTransform = this.genDecFromValue(arrSchema.items, "e"); return { code: "", result: `a[i++].map(e=>${itemTransform})` }; } return { code: "", result: "a[i++]" }; } if (z.schema.isObject(schema)) { const fields = this.getObjectFields(schema as TObject); // Check if simple (all required primitives) const simple = fields.every( ({ isOpt, isNullable, inner }) => !isOpt && !isNullable && !z.schema.isObject(inner) && !z.schema.isArray(inner), ); if (simple) { const fieldExprs = fields.map( ({ key }) => `${JSON.stringify(key)}:a[i++]`, ); return { code: "", result: `{${fieldExprs.join(",")}}` }; } let code = `const ${v}={};`; for (const { key, isOpt, isNullable, inner } of fields) { const sk = JSON.stringify(key); if (isOpt) { const nested = this.genDecFromValue(inner, "t"); code += `{const t=a[i++];if(t!==null){${v}[${sk}]=${nested};}}`; } else if (isNullable) { const nested = this.genDecFromValue(inner, "t"); code += `{const t=a[i++];if(t===null){${v}[${sk}]=null;}else{${v}[${sk}]=${nested};}}`; } else if (z.schema.isObject(inner)) { const nested = this.genDecFromValue(inner, "a[i++]"); code += `${v}[${sk}]=${nested};`; } else if (z.schema.isArray(inner)) { // Handle arrays - check if items need transformation const arrSchema = inner as TArray; if (z.schema.isObject(arrSchema.items)) { const itemTransform = this.genDecFromValue(arrSchema.items, "e"); code += `${v}[${sk}]=a[i++].map(e=>${itemTransform});`; } else { code += `${v}[${sk}]=a[i++];`; } } else { code += `${v}[${sk}]=a[i++];`; } } return { code, result: v }; } if (z.schema.isOptional(schema) || z.schema.isUnion(schema)) { const inner = this.unwrap(schema); const innerDec = this.genDec(inner); const nullVal = this.isNullable(schema) ? "null" : "undefined"; return { code: `const ${v}t=a[i++];let ${v};if(${v}t===null){${v}=${nullVal};}else{${innerDec.code.replace(/a\[i\+\+\]/g, `${v}t`)}${v}=${innerDec.result.replace(/a\[i\+\+\]/g, `${v}t`)};}`, result: v, }; } return { code: "", result: "a[i++]" }; } protected genDecFromValue(schema: TSchema, expr: string): string { if (this.isLeaf(schema)) { return expr; } if (z.schema.isBigInt(schema)) { return `BigInt(${expr}.slice(0,-1))`; } if (z.schema.isArray(schema)) { const items = schema.items as TSchema; if (z.schema.isObject(items) || z.schema.isBigInt(items)) { const v = this.nextVar(); return `${expr}.map(${v}=>${this.genDecFromValue(items, v)})`; } return expr; } if (z.schema.isObject(schema)) { const objSchema = schema as TObject; const props = objSchema.properties as Record<string, TSchema>; const keys = Object.keys(props); const v = this.nextVar(); const fields = keys.map((k, idx) => { const inner = this.unwrap(props[k]); const innerExpr = `${v}[${idx}]`; const sk = JSON.stringify(k); if (z.schema.isObject(inner)) { return `${sk}:${this.genDecFromValue(inner, innerExpr)}`; } if (z.schema.isBigInt(inner)) { return `${sk}:BigInt(${innerExpr}.slice(0,-1))`; } return `${sk}:${innerExpr}`; }); return `((${v}=${expr})=>({${fields.join(",")}}))()`; } return expr; } // =========================================================================== // Helpers // =========================================================================== protected isLeaf(schema: TSchema): boolean { return z.schema.isScalar(schema) || this.isEnum(schema); } protected getObjectFields(schema: TObject): Array<{ key: string; isOpt: boolean; isNullable: boolean; inner: TSchema; }> { const props = schema.properties as Record<string, TSchema>; const req = new Set(z.schema.requiredKeys(schema)); return Object.keys(props).map((key) => { const ps = props[key]; return { key, isOpt: !req.has(key) || z.schema.isOptional(ps), isNullable: this.isNullable(ps), inner: this.unwrap(ps), }; }); } protected isEnum(schema: TSchema): boolean { return ( "enum" in schema && Array.isArray((schema as { enum?: unknown[] }).enum) && ((schema as { enum?: unknown[] }).enum?.length ?? 0) > 0 ); } protected isNullable(schema: TSchema): boolean { // zod represents `z.nullable(x)` as a `ZodNullable` wrapper (and // `z.nullable(x).optional()` nests it inside a `ZodOptional`), so walk the // optional/nullable wrapper chain rather than only inspecting a union. let cur: any = schema; while (cur) { if (z.schema.isNullable(cur)) return true; if (z.schema.isOptional(cur) && typeof cur.unwrap === "function") { cur = cur.unwrap(); continue; } break; } // typebox-era nullable was a union `[schema, null]`. if (z.schema.isUnion(schema)) { const unionSchema = schema as TUnion; return unionSchema.anyOf?.some((s: any) => z.schema.isNull(s)) ?? false; } return false; } protected unwrap(schema: TSchema): TSchema { // Peel zod optional/nullable/default wrappers (`z.optional`, `z.nullable`). // Without this the codec recurses forever on an optional field: the local // unwrap only knew the typebox union representation and returned the same // `ZodOptional` schema unchanged. const peeled = z.schema.unwrap(schema); // typebox-era nullable union `[schema, null]` — pick the first non-null. if (z.schema.isUnion(peeled)) { const unionSchema = peeled as TUnion; if (Array.isArray(unionSchema.anyOf)) { return ( (unionSchema.anyOf.find( (s: any) => !z.schema.isNull(s), ) as TSchema) || peeled ); } } return peeled; } /** * Reconstruct an object from a parsed array (for when input is already parsed). */ protected reconstructObject(schema: TSchema, arr: any[]): any { if (!z.schema.isObject(schema)) return arr; const result: Record<string, any> = {}; let i = 0; for (const { key, isOpt, isNullable, inner } of this.getObjectFields( schema as TObject, )) { const val = arr[i++]; if (isOpt) { if (val !== null) { result[key] = z.schema.isObject(inner) ? this.reconstructObject(inner, val) : val; } } else if (isNullable) { result[key] = val === null ? null : z.schema.isObject(inner) ? this.reconstructObject(inner, val) : val; } else { result[key] = z.schema.isObject(inner) ? this.reconstructObject(inner, val) : val; } } return result; } }