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
+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)`);