@genkit-ai/ai
Version:
Genkit AI framework generative AI APIs.
398 lines (372 loc) • 12.7 kB
text/typescript
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* A tiny, dependency-free RFC 6902 (JSON Patch) implementation.
*
* This module is intentionally self-contained and browser-safe (no Node APIs,
* no runtime dependencies) so it can be shared by the in-process server agent
* and the browser-facing agent client.
*
* Genkit uses JSON Patch to stream incremental changes to a session's custom
* state (`AgentStreamChunk.customPatch`). The {@link diff} helper only emits
* `add` / `remove` / `replace` operations (a valid RFC 6902 subset - `move` /
* `copy` are optimizations we deliberately skip), while {@link applyPatch}
* understands the full operation set for interoperability.
*
* @module json-patch
*/
/**
* A single RFC 6902 (JSON Patch) operation.
*/
export interface JsonPatchOperation {
op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test';
/** A JSON Pointer (RFC 6901) to the target location, e.g. `"/agentStatus"`. */
path: string;
/** Source pointer; required for `move` and `copy`. */
from?: string;
/** New value; required for `add`, `replace`, and `test`. */
value?: any;
}
/**
* An RFC 6902 JSON Patch: an ordered list of operations.
*/
export type JsonPatch = JsonPatchOperation[];
/**
* Escapes a single JSON Pointer reference token per RFC 6901 (`~` → `~0`,
* `/` → `~1`).
*/
function escapeToken(token: string): string {
return token.replace(/~/g, '~0').replace(/\//g, '~1');
}
/**
* Unescapes a single JSON Pointer reference token per RFC 6901.
*/
function unescapeToken(token: string): string {
return token.replace(/~1/g, '/').replace(/~0/g, '~');
}
/**
* Reference tokens that could be used to pollute `Object.prototype` (or an
* object's constructor) when walking into an existing object. We reject these
* outright since patches may originate from untrusted, server-sent data.
*/
const FORBIDDEN_TOKENS = new Set(['__proto__', 'prototype', 'constructor']);
/**
* Parses a JSON Pointer string into its reference tokens.
*
* The root pointer (`""`) parses to an empty array.
*
* Reference tokens that could lead to prototype pollution (`__proto__`,
* `prototype`, `constructor`) are rejected.
*/
function parsePointer(pointer: string): string[] {
if (pointer === '') return [];
if (pointer[0] !== '/') {
throw new Error(`Invalid JSON Pointer: "${pointer}" must start with "/".`);
}
const tokens = pointer.slice(1).split('/').map(unescapeToken);
for (const token of tokens) {
if (FORBIDDEN_TOKENS.has(token)) {
throw new Error(
`Invalid JSON Pointer: "${pointer}" contains forbidden token "${token}".`
);
}
}
return tokens;
}
/**
* Returns `true` for values that are plain JSON objects (not arrays / null).
*/
function isObject(value: unknown): value is Record<string, any> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Lists the keys of a plain object whose values are not `undefined`.
*
* JSON has no `undefined`: `JSON.stringify` drops such members entirely, so
* `{ a: undefined }` and `{}` serialize identically. We mirror that here so
* that diffing/equality match how state is actually persisted (and so we never
* emit `undefined`-valued `add`/`replace` ops, which serialize to a valueless,
* meaningless operation).
*/
function definedKeys(obj: Record<string, any>): string[] {
return Object.keys(obj).filter((key) => obj[key] !== undefined);
}
/**
* Deep structural equality for JSON-serializable values.
*
* Object members whose value is `undefined` are treated as absent, matching
* JSON semantics (see {@link definedKeys}).
*/
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (typeof a !== typeof b) return false;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i])) return false;
}
return true;
}
if (isObject(a) && isObject(b)) {
const aKeys = definedKeys(a);
const bKeys = definedKeys(b);
if (aKeys.length !== bKeys.length) return false;
for (const key of aKeys) {
if (b[key] === undefined) return false;
if (!deepEqual(a[key], b[key])) return false;
}
return true;
}
return false;
}
/**
* Structured clone fallback that works across runtimes (uses the global
* `structuredClone` when available, otherwise JSON round-trips).
*/
function clone<T>(value: T): T {
if (value === undefined) return value;
if (typeof structuredClone === 'function') {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value));
}
/**
* Computes an RFC 6902 JSON Patch that transforms `from` into `to`.
*
* The diff is rooted at the document, so pointers are bare (e.g. `/agentStatus`,
* `/items/0`). Only `add` / `remove` / `replace` operations are emitted.
*
* When the two documents differ at the root in a way that cannot be expressed
* as member-level changes (e.g. an object becomes an array, or a primitive
* changes), a single whole-document `replace` at path `""` is returned.
*/
export function diff(from: unknown, to: unknown): JsonPatch {
const patch: JsonPatch = [];
diffRecursive(from, to, '', patch);
return patch;
}
function diffRecursive(
from: unknown,
to: unknown,
pointer: string,
patch: JsonPatch
): void {
if (deepEqual(from, to)) return;
// Both plain objects - recurse member by member. Members whose value is
// `undefined` are treated as absent (JSON has no `undefined`); this avoids
// emitting valueless `add`/`replace` ops and correctly turns "set to
// undefined" into a `remove`.
if (isObject(from) && isObject(to)) {
const keys = new Set([...Object.keys(from), ...Object.keys(to)]);
for (const key of keys) {
const childPointer = `${pointer}/${escapeToken(key)}`;
const inFrom = from[key] !== undefined;
const inTo = to[key] !== undefined;
if (inFrom && !inTo) {
patch.push({ op: 'remove', path: childPointer });
} else if (!inFrom && inTo) {
patch.push({ op: 'add', path: childPointer, value: clone(to[key]) });
} else if (inFrom && inTo) {
diffRecursive(from[key], to[key], childPointer, patch);
}
}
return;
}
// Both arrays - recurse by index, then add/remove the tail difference.
if (Array.isArray(from) && Array.isArray(to)) {
const min = Math.min(from.length, to.length);
for (let i = 0; i < min; i++) {
diffRecursive(from[i], to[i], `${pointer}/${i}`, patch);
}
if (to.length > from.length) {
for (let i = from.length; i < to.length; i++) {
// Appends use the "-" end-of-array token per RFC 6902.
patch.push({ op: 'add', path: `${pointer}/-`, value: clone(to[i]) });
}
} else if (from.length > to.length) {
// Remove from the tail backwards so indices stay valid as we go.
for (let i = from.length - 1; i >= to.length; i--) {
patch.push({ op: 'remove', path: `${pointer}/${i}` });
}
}
return;
}
// Type mismatch or differing primitives - replace at this location.
patch.push({ op: 'replace', path: pointer, value: clone(to) });
}
/**
* Applies an RFC 6902 JSON Patch to `document`, returning the new value.
*
* The input is not mutated; a clone is patched and returned. Operating on the
* root pointer (`""`) replaces / adds the whole document.
*
* Apply is intentionally lenient to keep streaming robust: applying an `add` /
* `replace` whose parent container is missing initializes the parent as an
* object, and a `remove` / `replace` targeting a missing member is a no-op
* rather than an error. `test` operations are honored and throw on mismatch.
*/
export function applyPatch<T = any>(document: T, patch: JsonPatch): T {
let doc: any = clone(document);
for (const op of patch) {
doc = applyOperation(doc, op);
}
return doc as T;
}
function applyOperation(doc: any, op: JsonPatchOperation): any {
const tokens = parsePointer(op.path);
// Root operations replace / set the entire document.
if (tokens.length === 0) {
switch (op.op) {
case 'add':
case 'replace':
return clone(op.value);
case 'remove':
return undefined;
case 'test':
if (!deepEqual(doc, op.value)) {
throw new Error(`JSON Patch 'test' failed at root.`);
}
return doc;
case 'move':
case 'copy': {
const value = clone(getValue(doc, parsePointer(op.from!)));
return value;
}
default:
throw new Error(`Unsupported JSON Patch op: ${(op as any).op}`);
}
}
// Lenient: initialize a missing root container so member-level adds/replaces
// still land (e.g. applying `/status` onto an undefined document).
if (doc == null && (op.op === 'add' || op.op === 'replace')) {
doc = {};
}
switch (op.op) {
case 'add':
setValue(doc, tokens, clone(op.value), /* isAdd= */ true);
return doc;
case 'replace':
setValue(doc, tokens, clone(op.value), /* isAdd= */ false);
return doc;
case 'remove':
removeValue(doc, tokens);
return doc;
case 'test': {
const actual = getValue(doc, tokens);
if (!deepEqual(actual, op.value)) {
throw new Error(`JSON Patch 'test' failed at "${op.path}".`);
}
return doc;
}
case 'move': {
const fromTokens = parsePointer(op.from!);
const value = clone(getValue(doc, fromTokens));
removeValue(doc, fromTokens);
setValue(doc, tokens, value, /* isAdd= */ true);
return doc;
}
case 'copy': {
const value = clone(getValue(doc, parsePointer(op.from!)));
setValue(doc, tokens, value, /* isAdd= */ true);
return doc;
}
default:
throw new Error(`Unsupported JSON Patch op: ${(op as any).op}`);
}
}
/**
* Reads the value at `tokens`, returning `undefined` for any missing segment.
*/
function getValue(doc: any, tokens: string[]): any {
let cur = doc;
for (const token of tokens) {
if (cur == null) return undefined;
cur = Array.isArray(cur) ? cur[Number(token)] : cur[token];
}
return cur;
}
/**
* Sets the value at `tokens`, creating intermediate object containers as
* needed. When `isAdd` is true and the parent is an array, the special `-`
* token appends and a numeric token inserts at that index.
*/
function setValue(
doc: any,
tokens: string[],
value: any,
isAdd: boolean
): void {
const parent = ensureParent(doc, tokens);
if (parent == null) return; // Lenient: nothing to set onto.
const last = tokens[tokens.length - 1];
if (Array.isArray(parent)) {
if (last === '-') {
parent.push(value);
return;
}
const idx = Number(last);
if (Number.isNaN(idx)) return;
if (isAdd) {
parent.splice(idx, 0, value);
} else {
parent[idx] = value;
}
return;
}
parent[last] = value;
}
/**
* Removes the value at `tokens`. Missing members are a no-op.
*/
function removeValue(doc: any, tokens: string[]): void {
const parent = getValue(doc, tokens.slice(0, -1));
if (parent == null) return;
const last = tokens[tokens.length - 1];
if (Array.isArray(parent)) {
const idx = Number(last);
if (!Number.isNaN(idx) && idx >= 0 && idx < parent.length) {
parent.splice(idx, 1);
}
return;
}
if (isObject(parent)) {
delete parent[last];
}
}
/**
* Walks to the parent container of `tokens`, lazily creating intermediate
* objects for missing segments so leniently-applied patches still land.
*/
function ensureParent(doc: any, tokens: string[]): any {
let cur = doc;
for (let i = 0; i < tokens.length - 1; i++) {
const token = tokens[i];
if (cur == null) return undefined;
const next = Array.isArray(cur) ? cur[Number(token)] : cur[token];
if (next == null || typeof next !== 'object') {
const created: any = {};
if (Array.isArray(cur)) {
cur[Number(token)] = created;
} else {
cur[token] = created;
}
cur = created;
} else {
cur = next;
}
}
return cur;
}