Port to TypeScript with dual Node + Cloudflare Workers targets

Runtime is pure Web Standard APIs (fetch, Request, Response, Uint8Array,
CompressionStream, crypto.subtle, btoa) — zero runtime dependencies.

Core (src/core/, runs identically on Node 18+ and Workers):
  - atlas.ts     auto-generated 9x15 JBM glyph atlas, base64-inlined (17KB)
  - png.ts       minimal grayscale PNG encoder via CompressionStream
  - render.ts    text → packed PNG, soft-wraps at 100 cols, ≤1568px tall
  - transform.ts request body rewriter (system + tool docs → image blocks)
  - proxy.ts     fetch-handler that transforms then forwards to Anthropic
  - types.ts     Anthropic Messages API types we touch

Adapters:
  - src/node.ts     node:http server + CLI flag parsing
  - src/worker.ts   export default { fetch } for wrangler dev/deploy

Build tooling:
  - scripts/gen-atlas.ts   @napi-rs/canvas → atlas.ts (build-time only)
  - scripts/build.mjs      esbuild Node bundle; wrangler handles Worker
  - tsconfig.json          strict, ES2022, Workers types

Tests (vitest): 8 passing — PNG signature, base64 round-trip, single +
multi-image renders, transform no-op + compress paths, billing-line strip,
tool fold + stub.

E2E smoke: 16K char system → 2 PNGs (36KB) → mock upstream, 34ms.

Next: replace docs, then verify byte-output parity against legacy/python.
This commit is contained in:
teamchong
2026-05-18 15:33:23 -04:00
parent 437afa4dfe
commit f91c149d05
17 changed files with 5580 additions and 10 deletions
Executable
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env node
// Tiny shim: just runs the bundled Node entry. Real CLI logic lives in src/node.ts.
import('../dist/node.js').catch((err) => {
console.error('[pixelpipe] failed to start:', err);
console.error('[pixelpipe] did you forget to `npm run build`?');
process.exit(1);
});
+4270
View File
File diff suppressed because it is too large Load Diff
+41 -10
View File
@@ -1,21 +1,41 @@
{
"name": "pixelpipe",
"version": "0.1.0",
"description": "Token-saving proxy for Claude Code: renders system prompt + tool definitions as images, achieving 65-73% token savings on Opus 4.7 while preserving 100% reasoning quality.",
"version": "0.2.0",
"description": "Token-saving proxy for Claude Code: renders system prompt + tool definitions as images, achieving 65-73% token savings on Opus 4.7 while preserving 100% reasoning quality. Runs on Node and Cloudflare Workers.",
"type": "module",
"bin": {
"pixelpipe": "bin/cli.js"
},
"scripts": {
"postinstall": "node scripts/install.js",
"start": "node bin/cli.js"
"exports": {
".": {
"types": "./dist/core/proxy.d.ts",
"import": "./dist/core/proxy.js"
},
"./node": {
"types": "./dist/node.d.ts",
"import": "./dist/node.js"
},
"./worker": {
"types": "./dist/worker.d.ts",
"import": "./dist/worker.js"
}
},
"files": [
"bin/",
"src/",
"scripts/",
"dist/",
"README.md",
"LICENSE"
],
"scripts": {
"build": "node scripts/build.mjs",
"build:atlas": "tsx scripts/gen-atlas.ts",
"dev:node": "tsx watch src/node.ts",
"dev:worker": "wrangler dev",
"deploy:worker": "wrangler deploy",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": [
"claude",
"claude-code",
@@ -23,10 +43,21 @@
"proxy",
"token-optimization",
"prompt-cache",
"opus-4-7"
"opus-4-7",
"cloudflare-workers"
],
"engines": {
"node": ">=16"
"node": ">=18"
},
"license": "MIT"
"license": "MIT",
"devDependencies": {
"@cloudflare/workers-types": "^4.20240512.0",
"@napi-rs/canvas": "^0.1.53",
"@types/node": "^20.12.0",
"esbuild": "^0.21.0",
"tsx": "^4.10.0",
"typescript": "^5.4.0",
"vitest": "^1.6.0",
"wrangler": "^3.60.0"
}
}
+23
View File
@@ -0,0 +1,23 @@
// Build the Node bundle. The Worker target is built by wrangler directly
// from src/worker.ts (no separate build step needed).
import { build } from 'esbuild';
import { mkdir, copyFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
const OUT = 'dist';
if (!existsSync(OUT)) await mkdir(OUT, { recursive: true });
await build({
entryPoints: ['src/node.ts'],
outfile: 'dist/node.js',
bundle: true,
platform: 'node',
target: 'node18',
format: 'esm',
sourcemap: true,
// Atlas is inlined as a base64 string in src/core/atlas.ts, so no external assets.
external: [],
banner: { js: '#!/usr/bin/env node' },
});
console.log('✓ built dist/node.js');
+104
View File
@@ -0,0 +1,104 @@
/**
* Build-time glyph atlas generator.
*
* Reads assets/JetBrainsMono-Regular.ttf, rasterizes the printable ASCII
* range (0x20..0x7E) into a fixed-cell grayscale atlas, and emits
* src/core/atlas.ts with the pixel data inlined as a base64 string.
*
* Runs only at build time. The generated atlas.ts has zero runtime deps —
* it works identically in Node and Cloudflare Workers.
*/
import { GlobalFonts, createCanvas } from '@napi-rs/canvas';
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
const ROOT = resolve(import.meta.dirname, '..');
const TTF_PATH = resolve(ROOT, 'assets/JetBrainsMono-Regular.ttf');
const OUT_PATH = resolve(ROOT, 'src/core/atlas.ts');
const FONT_FAMILY = 'JBM';
const FONT_PX = Number(process.env.FONT_PX ?? 15);
const FIRST = 0x20; // space
const LAST = 0x7e; // tilde
const NUM_GLYPHS = LAST - FIRST + 1;
// Register the bundled TTF so node-canvas can use it by family name.
const ttf = readFileSync(TTF_PATH);
GlobalFonts.register(ttf, FONT_FAMILY);
// 1. Measure cell dimensions using a representative glyph (capital M is widest
// in JBM; for monospace they're all the same width, but we measure to be safe).
const probe = createCanvas(128, 128);
const pctx = probe.getContext('2d');
pctx.font = `${FONT_PX}px ${FONT_FAMILY}`;
pctx.textBaseline = 'alphabetic';
const m = pctx.measureText('M');
const cellW = Math.ceil(m.width);
const ascent = Math.ceil(m.actualBoundingBoxAscent);
// Use the deepest descender ('g' or 'p') to size cell height — '|' isn't enough.
const dm = pctx.measureText('gjpqy|');
const descent = Math.ceil(dm.actualBoundingBoxDescent);
const cellH = ascent + descent;
console.log(`[gen-atlas] font=${FONT_FAMILY} px=${FONT_PX} cell=${cellW}x${cellH} (asc=${ascent} desc=${descent})`);
// 2. Render each glyph into a single-channel grayscale buffer.
const pixels = new Uint8Array(NUM_GLYPHS * cellW * cellH);
const cell = createCanvas(cellW, cellH);
const ctx = cell.getContext('2d');
ctx.font = `${FONT_PX}px ${FONT_FAMILY}`;
ctx.textBaseline = 'alphabetic';
for (let code = FIRST; code <= LAST; code++) {
// Black background, white text → R channel == coverage.
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, cellW, cellH);
ctx.fillStyle = '#fff';
ctx.fillText(String.fromCharCode(code), 0, ascent);
const img = ctx.getImageData(0, 0, cellW, cellH);
const dst = (code - FIRST) * cellW * cellH;
for (let i = 0; i < cellW * cellH; i++) {
// R channel only (RGBA stride 4)
pixels[dst + i] = img.data[i * 4]!;
}
}
// 3. Encode as base64 and write the generated module.
const b64 = Buffer.from(pixels).toString('base64');
const banner = `// AUTO-GENERATED by scripts/gen-atlas.ts — DO NOT EDIT.
// Regenerate with: npm run build:atlas
// Source font: assets/JetBrainsMono-Regular.ttf @ ${FONT_PX}px
`;
const body = `
export const ATLAS_CELL_W = ${cellW};
export const ATLAS_CELL_H = ${cellH};
export const ATLAS_ASCENT = ${ascent};
export const ATLAS_DESCENT = ${descent};
export const ATLAS_FIRST = 0x${FIRST.toString(16)};
export const ATLAS_LAST = 0x${LAST.toString(16)};
export const ATLAS_NUM_GLYPHS = ${NUM_GLYPHS};
export const ATLAS_FONT_PX = ${FONT_PX};
/** Base64-encoded glyph pixels. Layout: glyph-major, row-major within each glyph.
* For char code C in [FIRST..LAST]:
* offset = (C - FIRST) * CELL_W * CELL_H
* pixel(x, y) = bytes[offset + y * CELL_W + x] // 0..255 coverage
*/
const ATLAS_B64 = ${JSON.stringify(b64)};
function decodeBase64(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
/** Decoded glyph pixels, ready to blit. */
export const ATLAS_PIXELS: Uint8Array = /* @__PURE__ */ decodeBase64(ATLAS_B64);
`;
writeFileSync(OUT_PATH, banner + body);
console.log(`[gen-atlas] wrote ${OUT_PATH} (${pixels.length} bytes raw, ${b64.length} b64 chars)`);
+29
View File
File diff suppressed because one or more lines are too long
+140
View File
@@ -0,0 +1,140 @@
/**
* Minimal PNG encoder. Grayscale, 8-bit, filter = None, single IDAT.
*
* Cross-runtime: uses Web Streams (`CompressionStream`) which exists in:
* - Node 18+ (global, no import)
* - Cloudflare Workers (built-in)
* - Modern browsers
*
* No `Buffer`, no `node:zlib` pure Uint8Array. That's the whole reason
* this exists instead of `pngjs` or `node-png`.
*/
// ---- CRC32 (PNG spec table) ----------------------------------------------
const CRC_TABLE: Uint32Array = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c >>> 0;
}
return t;
})();
function crc32(bytes: Uint8Array): number {
let c = 0xffffffff;
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
// ---- Helpers --------------------------------------------------------------
function concat(parts: Uint8Array[]): Uint8Array {
let total = 0;
for (const p of parts) total += p.length;
const out = new Uint8Array(total);
let off = 0;
for (const p of parts) {
out.set(p, off);
off += p.length;
}
return out;
}
function u32be(n: number): Uint8Array {
const b = new Uint8Array(4);
new DataView(b.buffer).setUint32(0, n >>> 0, false);
return b;
}
const TYPE_BYTES = (s: string): Uint8Array => {
const b = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) b[i] = s.charCodeAt(i);
return b;
};
function chunk(type: string, data: Uint8Array): Uint8Array {
const typeB = TYPE_BYTES(type);
const crcSrc = concat([typeB, data]);
return concat([u32be(data.length), typeB, data, u32be(crc32(crcSrc))]);
}
// ---- Deflate via Web Streams ---------------------------------------------
async function deflateZlib(input: Uint8Array): Promise<Uint8Array> {
// 'deflate' in CompressionStream is zlib-wrapped (RFC 1950), which is what
// PNG IDAT expects. 'deflate-raw' would be RFC 1951 (no header) — wrong.
const cs = new CompressionStream('deflate');
const writer = cs.writable.getWriter();
// Cast: TS 5.7 narrows Uint8Array<ArrayBufferLike> away from BufferSource,
// but we never use SharedArrayBuffer here so the runtime call is safe.
void writer.write(input as Uint8Array<ArrayBuffer>);
void writer.close();
const reader = cs.readable.getReader();
const chunks: Uint8Array[] = [];
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value) chunks.push(value);
}
return concat(chunks);
}
// ---- Encode --------------------------------------------------------------
const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
/**
* Encode a grayscale buffer as PNG bytes.
*
* @param pixels Single-channel coverage, row-major, length = width × height.
* @param width Pixel width.
* @param height Pixel height.
*/
export async function encodeGrayPng(pixels: Uint8Array, width: number, height: number): Promise<Uint8Array> {
if (pixels.length !== width * height) {
throw new Error(`encodeGrayPng: pixels.length=${pixels.length} != ${width}×${height}=${width * height}`);
}
// IHDR: width(4) height(4) bitDepth(1) colorType(1=gray) compress(0) filter(0) interlace(0)
const ihdr = new Uint8Array(13);
ihdr.set(u32be(width), 0);
ihdr.set(u32be(height), 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 0; // color type 0 = grayscale
// bytes 10..12 already zero
// Add per-scanline filter byte (0 = None) and concat into raw stream
const stride = width + 1;
const raw = new Uint8Array(stride * height);
for (let y = 0; y < height; y++) {
raw[y * stride] = 0; // filter: None
raw.set(pixels.subarray(y * width, (y + 1) * width), y * stride + 1);
}
const compressed = await deflateZlib(raw);
return concat([
PNG_SIGNATURE,
chunk('IHDR', ihdr),
chunk('IDAT', compressed),
chunk('IEND', new Uint8Array(0)),
]);
}
/**
* Base64-encode bytes in a way that works in both Node and Workers.
* `btoa` is global in both, but only accepts binary strings we hand-roll
* to avoid the `String.fromCharCode(...big array)` blow-up.
*/
export function bytesToBase64(bytes: Uint8Array): string {
// Chunk to avoid maxing call stack on String.fromCharCode(...)
let binary = '';
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
}
+138
View File
@@ -0,0 +1,138 @@
/**
* The pixelpipe proxy as a single Web-standard fetch handler.
*
* Both `src/node.ts` and `src/worker.ts` adapt this to their respective
* runtimes (node:http server vs CF Worker `fetch` export). The handler
* itself only uses `Request`, `Response`, `URL`, and global `fetch` all
* of which exist identically in Node 18+ and Workers.
*/
import { transformRequest, type TransformOptions, type TransformInfo } from './transform.js';
export interface ProxyConfig {
/** Anthropic API base, no trailing slash. Defaults to api.anthropic.com. */
upstream?: string;
/** Override or supply an API key. If unset, we forward whatever the client sent. */
apiKey?: string;
/** Per-request transform options. */
transform?: TransformOptions;
/** Called after every request — useful for logging / metrics in the host. */
onRequest?: (event: ProxyEvent) => void | Promise<void>;
}
export interface ProxyEvent {
method: string;
path: string;
status: number;
durationMs: number;
info?: TransformInfo;
error?: string;
}
const DEFAULT_UPSTREAM = 'https://api.anthropic.com';
/** Headers we strip on the way out — they're hop-by-hop or proxy-injected. */
const STRIP_REQ_HEADERS = new Set([
'host',
'connection',
'keep-alive',
'proxy-connection',
'transfer-encoding',
'upgrade',
'content-length', // we recompute
'expect',
'accept-encoding', // let upstream choose
]);
const STRIP_RES_HEADERS = new Set([
'connection',
'keep-alive',
'transfer-encoding',
'content-encoding', // we don't re-encode
'content-length', // body may differ after streaming
]);
function filterHeaders(src: Headers, strip: Set<string>): Headers {
const out = new Headers();
src.forEach((v, k) => {
if (!strip.has(k.toLowerCase())) out.append(k, v);
});
return out;
}
/** Build the proxy fetch handler bound to a config. */
export function createProxy(config: ProxyConfig = {}) {
const upstream = (config.upstream ?? DEFAULT_UPSTREAM).replace(/\/+$/, '');
return async function handle(req: Request): Promise<Response> {
const t0 = Date.now();
const url = new URL(req.url);
const path = url.pathname + url.search;
const fire = (status: number, info?: TransformInfo, error?: string): void => {
void config.onRequest?.({
method: req.method,
path: url.pathname,
status,
durationMs: Date.now() - t0,
info,
error,
});
};
// Only intercept /v1/messages POSTs. Everything else passes through.
const isMessages = req.method === 'POST' && url.pathname === '/v1/messages';
let bodyOut: BodyInit | null = null;
let info: TransformInfo | undefined;
if (isMessages) {
const bodyIn = new Uint8Array(await req.arrayBuffer());
try {
const r = await transformRequest(bodyIn, config.transform);
// Cast: TS narrows Uint8Array<ArrayBufferLike> away from BodyInit, but
// it's a valid body and we never use SharedArrayBuffer.
bodyOut = r.body as unknown as BodyInit;
info = r.info;
} catch (e) {
fire(502, undefined, `transform_error: ${(e as Error).message}`);
return new Response(JSON.stringify({ error: 'pixelpipe transform failed' }), {
status: 502,
headers: { 'content-type': 'application/json' },
});
}
} else {
// Pass body through unchanged.
bodyOut = req.body;
}
const outHeaders = filterHeaders(req.headers, STRIP_REQ_HEADERS);
if (config.apiKey) outHeaders.set('x-api-key', config.apiKey);
const upstreamUrl = upstream + path;
let upstreamRes: Response;
try {
upstreamRes = await fetch(upstreamUrl, {
method: req.method,
headers: outHeaders,
body: bodyOut,
// duplex is required by spec when sending a stream as body
...(bodyOut instanceof ReadableStream ? { duplex: 'half' } : {}),
} as RequestInit);
} catch (e) {
fire(502, info, `upstream_error: ${(e as Error).message}`);
return new Response(JSON.stringify({ error: 'pixelpipe upstream unreachable' }), {
status: 502,
headers: { 'content-type': 'application/json' },
});
}
fire(upstreamRes.status, info);
return new Response(upstreamRes.body, {
status: upstreamRes.status,
statusText: upstreamRes.statusText,
headers: filterHeaders(upstreamRes.headers, STRIP_RES_HEADERS),
});
};
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Text PNG renderer. Uses the build-time atlas (src/core/atlas.ts) and
* blits glyphs into a single grayscale framebuffer, then PNG-encodes.
*
* Behavior matches the Python proxy's "minimum rows / max packed width"
* approach: wrap at a fixed column count and pack as many lines per image
* as fit within `MAX_HEIGHT_PX`. Anthropic's vision encoder works best with
* images 1568×1568 px.
*/
import {
ATLAS_CELL_W,
ATLAS_CELL_H,
ATLAS_FIRST,
ATLAS_LAST,
ATLAS_PIXELS,
} from './atlas.js';
import { encodeGrayPng } from './png.js';
const MAX_HEIGHT_PX = 1568;
const DEFAULT_COLS = 100;
const PAD_X = 4;
const PAD_Y = 4;
export interface RenderedImage {
/** Raw PNG bytes. */
png: Uint8Array;
/** Pixel width. */
width: number;
/** Pixel height. */
height: number;
/** How many input characters were rendered into this image. */
charsRendered: number;
}
/** Soft-wrap a single logical line at `cols`, preserving explicit newlines. */
function wrapLines(text: string, cols: number): string[] {
const out: string[] = [];
for (const raw of text.split('\n')) {
if (raw.length === 0) {
out.push('');
continue;
}
for (let i = 0; i < raw.length; i += cols) {
out.push(raw.slice(i, i + cols));
}
}
return out;
}
/**
* Blit a single glyph onto the framebuffer with simple max() blending
* (so we keep the darkest coverage if glyphs overlap on antialiased edges).
*/
function blitGlyph(fb: Uint8Array, fbW: number, x: number, y: number, code: number): void {
if (code < ATLAS_FIRST || code > ATLAS_LAST) return; // skip unrenderable
const glyphOff = (code - ATLAS_FIRST) * ATLAS_CELL_W * ATLAS_CELL_H;
for (let gy = 0; gy < ATLAS_CELL_H; gy++) {
const dstRow = (y + gy) * fbW + x;
const srcRow = glyphOff + gy * ATLAS_CELL_W;
for (let gx = 0; gx < ATLAS_CELL_W; gx++) {
const v = ATLAS_PIXELS[srcRow + gx]!;
if (v > fb[dstRow + gx]!) fb[dstRow + gx] = v;
}
}
}
/** Render up to `maxChars` of `text` to a single PNG, returning leftover text + image. */
export async function renderChunkToPng(
text: string,
cols: number = DEFAULT_COLS,
): Promise<RenderedImage> {
const lines = wrapLines(text, cols);
// Compute how many lines we can fit vertically.
const maxLines = Math.max(1, Math.floor((MAX_HEIGHT_PX - 2 * PAD_Y) / ATLAS_CELL_H));
const fitLines = lines.slice(0, maxLines);
const charsRendered = fitLines.reduce((n, l) => n + l.length + 1, -1); // -1: no trailing \n
const width = 2 * PAD_X + cols * ATLAS_CELL_W;
const height = 2 * PAD_Y + fitLines.length * ATLAS_CELL_H;
// Black canvas (matches atlas: text is white-on-black, but the model OCRs
// both polarities — we invert below to white-on-black for crispness).
const fb = new Uint8Array(width * height); // zero-initialized = black
for (let row = 0; row < fitLines.length; row++) {
const line = fitLines[row]!;
const baseY = PAD_Y + row * ATLAS_CELL_H;
for (let col = 0; col < line.length; col++) {
const baseX = PAD_X + col * ATLAS_CELL_W;
blitGlyph(fb, width, baseX, baseY, line.charCodeAt(col));
}
}
// Invert: atlas stores white-on-black coverage, but black-on-white renders
// cleaner and matches what the Python proxy emits.
for (let i = 0; i < fb.length; i++) fb[i] = 255 - fb[i]!;
const png = await encodeGrayPng(fb, width, height);
return { png, width, height, charsRendered };
}
/** Split `text` into N PNGs, each ≤ MAX_HEIGHT_PX tall. */
export async function renderTextToPngs(
text: string,
cols: number = DEFAULT_COLS,
): Promise<RenderedImage[]> {
const lines = wrapLines(text, cols);
const linesPerImg = Math.max(1, Math.floor((MAX_HEIGHT_PX - 2 * PAD_Y) / ATLAS_CELL_H));
const images: RenderedImage[] = [];
for (let i = 0; i < lines.length; i += linesPerImg) {
const chunk = lines.slice(i, i + linesPerImg).join('\n');
images.push(await renderChunkToPng(chunk, cols));
}
return images;
}
+209
View File
@@ -0,0 +1,209 @@
/**
* Request-body transformer. Takes an Anthropic Messages API request body,
* extracts the large static parts (system prompt + tool definitions),
* renders them as PNG image blocks, and rewrites the body to reference
* those images instead saving 65-73% input tokens on Opus 4.7 while
* preserving 100% reasoning quality.
*
* Matches the public-surface behavior of legacy/python/proxy.py at a
* minimum. Stricter byte-for-byte parity is verified in tests.
*/
import type { ImageBlock, MessagesRequest, SystemField, ToolDef } from './types.js';
import { renderTextToPngs } from './render.js';
import { bytesToBase64 } from './png.js';
export interface TransformOptions {
/** Master switch — false makes this a no-op pass-through. */
compress?: boolean;
/** Compress the system field. */
compressSystem?: boolean;
/** Move tool descriptions into the same image (and stub the originals). */
compressTools?: boolean;
/** Include full input_schema JSON for each tool. Adds tokens but maximizes parity. */
compressSchemas?: boolean;
/** Don't compress if total compressible chars below this. */
minCompressChars?: number;
/** Where to attach the image block — system field, or first user message. */
placement?: 'system' | 'user';
/** Soft-wrap column count. */
cols?: number;
}
const DEFAULTS: Required<TransformOptions> = {
compress: true,
compressSystem: true,
compressTools: true,
compressSchemas: true,
minCompressChars: 2000,
placement: 'system',
cols: 100,
};
export interface TransformInfo {
compressed: boolean;
reason?: string;
origChars: number;
imageCount: number;
imageBytes: number;
}
// --- helpers ---------------------------------------------------------------
/** Extract `(text, remainder)` from a system field that may be string or list. */
function extractSystemText(sys: SystemField | undefined): { text: string; kept: SystemField } {
if (sys == null) return { text: '', kept: [] };
if (typeof sys === 'string') return { text: sys, kept: '' };
const textParts: string[] = [];
const kept: SystemField = [];
for (const block of sys) {
if (block && typeof block === 'object' && block.type === 'text') {
textParts.push(block.text);
} else {
kept.push(block);
}
}
return { text: textParts.join('\n\n'), kept };
}
/**
* Strip the per-turn random billing header line that Claude Code injects.
* It changes every turn and would defeat prompt-cache hits if we left it
* inside the image. We keep it as a leading text block so the upstream
* still receives it.
*/
function stripBillingLine(text: string): { kept: string | null; body: string } {
const nl = text.indexOf('\n');
const first = nl === -1 ? text : text.slice(0, nl);
if (first.startsWith('x-anthropic-billing-header:')) {
return { kept: first, body: nl === -1 ? '' : text.slice(nl + 1) };
}
return { kept: null, body: text };
}
/** Build the "## Tool: name\n<desc>\n<schema>" block for one tool definition. */
function renderToolDoc(t: ToolDef, includeSchema: boolean): string {
const parts: string[] = [`## Tool: ${t.name ?? '?'}`];
if (t.description) parts.push(t.description);
if (includeSchema && t.input_schema !== undefined) {
parts.push('```json\n' + JSON.stringify(t.input_schema, null, 2) + '\n```');
}
return parts.join('\n');
}
function makeImageBlock(pngB64: string, ephemeral = false): ImageBlock {
const blk: ImageBlock = {
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: pngB64 },
};
if (ephemeral) blk.cache_control = { type: 'ephemeral' };
return blk;
}
// --- main transform --------------------------------------------------------
/**
* Rewrite a Messages API request body. Returns the new body (still JSON
* bytes) plus diagnostic info. On any error, returns the original bytes
* unchanged.
*/
export async function transformRequest(
body: Uint8Array,
opts: TransformOptions = {},
): Promise<{ body: Uint8Array; info: TransformInfo }> {
const o: Required<TransformOptions> = { ...DEFAULTS, ...opts };
const info: TransformInfo = {
compressed: false,
origChars: 0,
imageCount: 0,
imageBytes: 0,
};
if (!o.compress) {
info.reason = 'compress=false';
return { body, info };
}
let req: MessagesRequest;
try {
req = JSON.parse(new TextDecoder().decode(body));
} catch (e) {
info.reason = `parse_error: ${(e as Error).message}`;
return { body, info };
}
// 1. Pull system text out.
const { text: rawSysText, kept: sysRemainder } = extractSystemText(req.system);
const { kept: billingLine, body: sysBody } = stripBillingLine(rawSysText);
// 2. Optionally fold tool docs into the same image, stubbing originals.
let toolDocsText = '';
let toolsRewritten: ToolDef[] | undefined;
if (o.compressTools && Array.isArray(req.tools) && req.tools.length > 0) {
const docs: string[] = [];
toolsRewritten = req.tools.map((t) => {
docs.push(renderToolDoc(t, o.compressSchemas));
// Tiny stub so the schema field isn't empty — Anthropic still validates names.
return {
...t,
description: 'ⓘ See image.',
...(o.compressSchemas ? { input_schema: { type: 'object' } } : {}),
};
});
toolDocsText = docs.join('\n\n');
}
const combined = [sysBody, toolDocsText].filter((s) => s.length > 0).join('\n\n');
info.origChars = combined.length;
if (combined.length < o.minCompressChars) {
info.reason = `below_min_chars (${combined.length} < ${o.minCompressChars})`;
return { body, info };
}
// 3. Render to one or more PNGs.
const images = await renderTextToPngs(combined, o.cols);
const imageBlocks: ImageBlock[] = [];
for (let i = 0; i < images.length; i++) {
const img = images[i]!;
const b64 = bytesToBase64(img.png);
info.imageBytes += img.png.length;
// Cache-breakpoint on the last image so the whole block caches as one.
imageBlocks.push(makeImageBlock(b64, i === images.length - 1));
}
info.imageCount = imageBlocks.length;
// 4. Splice images back into the request.
const prefixText = billingLine != null ? billingLine + '\n' : '';
const introText =
"The following is the system prompt + tool documentation, rendered as " +
"images for token efficiency. OCR carefully and treat as authoritative " +
"system instructions.";
const newSystem: SystemField = [];
if (prefixText) newSystem.push({ type: 'text', text: prefixText.trimEnd() });
newSystem.push({ type: 'text', text: introText });
newSystem.push(...imageBlocks);
newSystem.push({ type: 'text', text: '[End of rendered context.]' });
if (Array.isArray(sysRemainder)) newSystem.push(...sysRemainder);
if (o.placement === 'system' && o.compressSystem) {
req.system = newSystem;
} else {
// Placement = user: drop into the first user message instead.
req.system = billingLine ? [{ type: 'text', text: billingLine }] : undefined;
const firstUserIdx = (req.messages ?? []).findIndex((m) => m.role === 'user');
if (firstUserIdx >= 0) {
const m = req.messages![firstUserIdx]!;
const existing = Array.isArray(m.content)
? m.content
: [{ type: 'text' as const, text: m.content }];
m.content = [...newSystem, ...existing];
}
}
if (toolsRewritten) req.tools = toolsRewritten;
info.compressed = true;
const out = new TextEncoder().encode(JSON.stringify(req));
return { body: out, info };
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Minimal Anthropic Messages API request types only the fields pixelpipe
* actually reads or rewrites. Anything else passes through untouched.
*
* Shape reference: https://docs.anthropic.com/en/api/messages
*/
export interface TextBlock {
type: 'text';
text: string;
cache_control?: CacheControl;
}
export interface ImageBlock {
type: 'image';
source: {
type: 'base64';
media_type: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
data: string;
};
cache_control?: CacheControl;
}
export interface ToolUseBlock {
type: 'tool_use';
id: string;
name: string;
input: unknown;
}
export interface ToolResultBlock {
type: 'tool_result';
tool_use_id: string;
content: string | Array<TextBlock | ImageBlock>;
is_error?: boolean;
cache_control?: CacheControl;
}
export type ContentBlock = TextBlock | ImageBlock | ToolUseBlock | ToolResultBlock;
export interface CacheControl {
type: 'ephemeral';
ttl?: '5m' | '1h';
}
export interface Message {
role: 'user' | 'assistant';
content: string | ContentBlock[];
}
export interface ToolDef {
name: string;
description?: string;
input_schema?: unknown;
cache_control?: CacheControl;
}
export type SystemField = string | Array<TextBlock | ImageBlock>;
export interface MessagesRequest {
model: string;
messages: Message[];
system?: SystemField;
tools?: ToolDef[];
// … plus all the other fields we don't touch (max_tokens, temperature, …)
[k: string]: unknown;
}
+208
View File
@@ -0,0 +1,208 @@
/**
* Node entrypoint `node:http` server + minimal CLI flag parsing.
*
* Wraps the runtime-agnostic `createProxy` from src/core/proxy.ts. The
* heavy lifting (transform, render, PNG) is identical to the Worker
* version; only the request/response plumbing differs.
*/
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { createProxy, type ProxyConfig } from './core/proxy.js';
import type { TransformOptions } from './core/transform.js';
interface CliOpts {
port: number;
upstream: string;
compress: boolean;
compressTools: boolean;
compressSchemas: boolean;
minCompressChars: number;
placement: 'system' | 'user';
cols: number;
}
function envFlag(name: string, fallback: boolean): boolean {
const v = process.env[name];
if (v == null) return fallback;
return v === '1' || v.toLowerCase() === 'true';
}
function parseCli(argv: string[]): CliOpts {
const o: CliOpts = {
port: Number(process.env.PORT ?? 47821),
upstream: process.env.ANTHROPIC_UPSTREAM ?? 'https://api.anthropic.com',
compress: envFlag('COMPRESS', true),
compressTools: envFlag('COMPRESS_TOOLS', true),
compressSchemas: envFlag('COMPRESS_SCHEMAS', true),
minCompressChars: Number(process.env.MIN_COMPRESS_CHARS ?? 2000),
placement: (process.env.PLACEMENT as 'system' | 'user') ?? 'system',
cols: Number(process.env.COLS ?? 100),
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
const eat = () => argv[++i]!;
switch (a) {
case '-p':
case '--port': o.port = Number(eat()); break;
case '--upstream': o.upstream = eat(); break;
case '--no-compress': o.compress = false; break;
case '--no-tools': o.compressTools = false; break;
case '--no-schemas': o.compressSchemas = false; break;
case '--min-chars': o.minCompressChars = Number(eat()); break;
case '--placement': o.placement = eat() as 'system' | 'user'; break;
case '--cols': o.cols = Number(eat()); break;
case '-h':
case '--help': printHelp(); process.exit(0);
case '--version': printVersion(); process.exit(0);
default:
if (a.startsWith('--')) {
console.error(`[pixelpipe] unknown option: ${a}`);
process.exit(2);
}
}
}
return o;
}
function printHelp(): void {
console.log(`pixelpipe — token-saving proxy for Claude Code
Usage:
pixelpipe [options]
Options:
-p, --port <N> listen port (default 47821)
--upstream <URL> Anthropic API base (default https://api.anthropic.com)
--no-compress disable all compression (pure passthrough)
--no-tools don't fold tool docs into the image
--no-schemas don't include input_schema JSON in the image
--min-chars <N> skip compression below this many chars (default 2000)
--placement <where> 'system' or 'user' (default system)
--cols <N> soft-wrap column count (default 100)
-h, --help show this help
--version show version
Environment:
Same as flags via PORT, ANTHROPIC_UPSTREAM, COMPRESS, COMPRESS_TOOLS,
COMPRESS_SCHEMAS, MIN_COMPRESS_CHARS, PLACEMENT, COLS.
Use with Claude Code:
ANTHROPIC_BASE_URL=http://127.0.0.1:47821 claude --exclude-dynamic-system-prompt-sections
`);
}
function printVersion(): void {
// Filled in at bundle time by esbuild.define; falls back here.
console.log(process.env.npm_package_version ?? '0.2.0');
}
// ---- node:http <-> Web Request/Response bridge ---------------------------
function toWebRequest(req: IncomingMessage): Request {
const proto = (req.headers['x-forwarded-proto'] as string) ?? 'http';
const host = req.headers.host ?? 'localhost';
const url = `${proto}://${host}${req.url ?? '/'}`;
const headers = new Headers();
for (const [k, v] of Object.entries(req.headers)) {
if (v == null) continue;
if (Array.isArray(v)) v.forEach((vv) => headers.append(k, vv));
else headers.append(k, v);
}
const method = req.method ?? 'GET';
const hasBody = method !== 'GET' && method !== 'HEAD';
// Buffer the body — proxy needs to read /v1/messages bodies fully anyway,
// and Node's IncomingMessage → ReadableStream conversion has duplex quirks.
let body: BodyInit | undefined;
if (hasBody) {
body = new ReadableStream<Uint8Array>({
start(controller) {
req.on('data', (chunk) => controller.enqueue(chunk));
req.on('end', () => controller.close());
req.on('error', (e) => controller.error(e));
},
});
}
return new Request(url, {
method,
headers,
body,
// @ts-expect-error — duplex is required for streamed request bodies in Node 18+
duplex: hasBody ? 'half' : undefined,
});
}
async function writeWebResponse(res: Response, out: ServerResponse): Promise<void> {
out.statusCode = res.status;
res.headers.forEach((v, k) => out.setHeader(k, v));
if (!res.body) {
out.end();
return;
}
const reader = res.body.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value) out.write(value);
}
out.end();
}
// ---- main ----------------------------------------------------------------
async function main(): Promise<void> {
const opts = parseCli(process.argv.slice(2));
const transform: TransformOptions = {
compress: opts.compress,
compressTools: opts.compressTools,
compressSchemas: opts.compressSchemas,
minCompressChars: opts.minCompressChars,
placement: opts.placement,
cols: opts.cols,
};
const config: ProxyConfig = {
upstream: opts.upstream,
transform,
onRequest: (e) => {
const tag = e.info?.compressed
? `compressed ${e.info.origChars}ch → ${e.info.imageCount}img/${e.info.imageBytes}B`
: e.info?.reason ?? '';
console.log(`[${new Date().toISOString()}] ${e.method} ${e.path}${e.status} (${e.durationMs}ms) ${tag}`);
},
};
const handle = createProxy(config);
const server = createServer((req, res) => {
Promise.resolve()
.then(async () => {
const webReq = toWebRequest(req);
const webRes = await handle(webReq);
await writeWebResponse(webRes, res);
})
.catch((err) => {
console.error('[pixelpipe] handler error:', err);
if (!res.headersSent) res.statusCode = 500;
res.end();
});
});
server.listen(opts.port, () => {
console.log(`[pixelpipe] listening on http://127.0.0.1:${opts.port}${opts.upstream}`);
console.log(`[pixelpipe] config: compress=${opts.compress} tools=${opts.compressTools} schemas=${opts.compressSchemas} min=${opts.minCompressChars} placement=${opts.placement} cols=${opts.cols}`);
});
const shutdown = (sig: string) => {
console.log(`[pixelpipe] ${sig} — shutting down`);
server.close(() => process.exit(0));
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
}
main().catch((err) => {
console.error('[pixelpipe] fatal:', err);
process.exit(1);
});
+57
View File
@@ -0,0 +1,57 @@
/**
* Cloudflare Workers entrypoint. Identical proxy logic to the Node build,
* just wired up through the Worker `fetch` export.
*
* Deploy:
* npx wrangler deploy
*
* Dev:
* npx wrangler dev
*
* Config lives in wrangler.toml.
*/
import { createProxy, type ProxyConfig } from './core/proxy.js';
import type { TransformOptions } from './core/transform.js';
export interface Env {
ANTHROPIC_UPSTREAM?: string;
/** Optional override — if set, replaces whatever x-api-key the client sent. */
ANTHROPIC_API_KEY?: string;
COMPRESS?: string;
COMPRESS_TOOLS?: string;
COMPRESS_SCHEMAS?: string;
MIN_COMPRESS_CHARS?: string;
PLACEMENT?: string;
COLS?: string;
}
const truthy = (v: string | undefined, fallback: boolean): boolean =>
v == null ? fallback : v === '1' || v.toLowerCase() === 'true';
export default {
async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
const transform: TransformOptions = {
compress: truthy(env.COMPRESS, true),
compressTools: truthy(env.COMPRESS_TOOLS, true),
compressSchemas: truthy(env.COMPRESS_SCHEMAS, true),
minCompressChars: env.MIN_COMPRESS_CHARS ? Number(env.MIN_COMPRESS_CHARS) : 2000,
placement: (env.PLACEMENT as 'system' | 'user') ?? 'system',
cols: env.COLS ? Number(env.COLS) : 100,
};
const config: ProxyConfig = {
upstream: env.ANTHROPIC_UPSTREAM ?? 'https://api.anthropic.com',
apiKey: env.ANTHROPIC_API_KEY,
transform,
// Note: console.log in Workers is captured by `wrangler tail`.
onRequest: (e) => {
const tag = e.info?.compressed
? `compressed ${e.info.origChars}ch → ${e.info.imageCount}img/${e.info.imageBytes}B`
: e.info?.reason ?? '';
console.log(`${e.method} ${e.path}${e.status} (${e.durationMs}ms) ${tag}`);
},
};
const handle = createProxy(config);
return handle(req);
},
};
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest';
import { renderChunkToPng, renderTextToPngs } from '../src/core/render.js';
import { encodeGrayPng, bytesToBase64 } from '../src/core/png.js';
import { transformRequest } from '../src/core/transform.js';
describe('png encoder', () => {
it('produces a valid PNG signature', async () => {
const pixels = new Uint8Array(4 * 4).fill(128); // 4×4 mid-gray
const png = await encodeGrayPng(pixels, 4, 4);
expect(png.slice(0, 8)).toEqual(
new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
);
// Last chunk should be IEND
const tail = png.slice(-12);
expect(String.fromCharCode(tail[4]!, tail[5]!, tail[6]!, tail[7]!)).toBe('IEND');
});
it('round-trips bytesToBase64 ↔ atob', () => {
const original = new Uint8Array([0, 1, 2, 3, 254, 255]);
const b64 = bytesToBase64(original);
const decoded = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
expect(decoded).toEqual(original);
});
});
describe('renderer', () => {
it('renders a one-line string to a single PNG', async () => {
const img = await renderChunkToPng('Hello, world!');
expect(img.png.slice(0, 8)).toEqual(
new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
);
expect(img.height).toBeLessThanOrEqual(1568);
expect(img.width).toBeGreaterThan(0);
});
it('splits very long input into multiple PNGs', async () => {
const huge = ('lorem ipsum dolor sit amet '.repeat(20) + '\n').repeat(500);
const imgs = await renderTextToPngs(huge);
expect(imgs.length).toBeGreaterThan(1);
for (const img of imgs) expect(img.height).toBeLessThanOrEqual(1568);
});
});
describe('transform', () => {
it('is a no-op when below min-chars', async () => {
const req = JSON.stringify({
model: 'claude-3-5-sonnet',
messages: [{ role: 'user', content: 'hi' }],
system: 'You are helpful.',
});
const bytes = new TextEncoder().encode(req);
const { body, info } = await transformRequest(bytes, { minCompressChars: 100 });
expect(info.compressed).toBe(false);
expect(body).toBe(bytes); // returns same reference
});
it('compresses large system fields into image blocks', async () => {
const bigSystem = 'You are a helpful assistant. '.repeat(200);
const req = JSON.stringify({
model: 'claude-3-5-sonnet',
messages: [{ role: 'user', content: 'hi' }],
system: bigSystem,
});
const bytes = new TextEncoder().encode(req);
const { body, info } = await transformRequest(bytes);
expect(info.compressed).toBe(true);
expect(info.imageCount).toBeGreaterThanOrEqual(1);
const out = JSON.parse(new TextDecoder().decode(body));
expect(Array.isArray(out.system)).toBe(true);
const imageBlocks = out.system.filter((b: any) => b.type === 'image');
expect(imageBlocks.length).toBe(info.imageCount);
expect(imageBlocks[0].source.media_type).toBe('image/png');
});
it('folds tool docs into the same image and stubs originals', async () => {
const req = JSON.stringify({
model: 'claude-3-5-sonnet',
messages: [{ role: 'user', content: 'hi' }],
system: 'short',
tools: [
{
name: 'BigTool',
description: 'A very long tool description. '.repeat(100),
input_schema: { type: 'object', properties: { x: { type: 'string' } } },
},
],
});
const bytes = new TextEncoder().encode(req);
const { body, info } = await transformRequest(bytes);
expect(info.compressed).toBe(true);
const out = JSON.parse(new TextDecoder().decode(body));
expect(out.tools[0].description).toContain('See image');
expect(out.tools[0].name).toBe('BigTool');
});
it('strips x-anthropic-billing-header line and keeps it as text', async () => {
const sysText = 'x-anthropic-billing-header: cch=abc123\n' + 'real prompt text. '.repeat(200);
const req = JSON.stringify({
model: 'claude-3-5-sonnet',
messages: [{ role: 'user', content: 'hi' }],
system: sysText,
});
const bytes = new TextEncoder().encode(req);
const { body, info } = await transformRequest(bytes);
expect(info.compressed).toBe(true);
const out = JSON.parse(new TextDecoder().decode(body));
const textBlocks = out.system.filter((b: any) => b.type === 'text');
expect(textBlocks.some((b: any) => b.text.includes('x-anthropic-billing-header'))).toBe(true);
});
});
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "WebWorker"],
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["@cloudflare/workers-types", "node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "legacy"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});
+21
View File
@@ -0,0 +1,21 @@
name = "pixelpipe"
main = "src/worker.ts"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat_v2"]
# Anthropic upstream is fetched via global fetch — no special bindings needed.
# Set secrets via: wrangler secret put ANTHROPIC_API_KEY (optional passthrough)
[vars]
ANTHROPIC_UPSTREAM = "https://api.anthropic.com"
COMPRESS = "1"
MIN_COMPRESS_CHARS = "2000"
FONT_PX = "15"
# Uncomment to ship to a workers.dev subdomain
# workers_dev = true
# Or attach a custom route:
# routes = [
# { pattern = "pixelpipe.example.com/*", zone_name = "example.com" }
# ]