diff --git a/browser/deno.json b/browser/deno.json index dc73ad2..bb592c0 100644 --- a/browser/deno.json +++ b/browser/deno.json @@ -52,7 +52,7 @@ ], "imports": { "@std/assert": "jsr:@std/assert@^1.0.16", - "@browserx/webgpu_x": "jsr:@browserx/webgpu_x", + "@browserx/webgpu_x": "../crates/webgpu_x/webgpu_x.ts", "@noble/ciphers/chacha": "npm:@noble/ciphers@1.2.1/chacha", "@noble/ciphers/utils": "npm:@noble/ciphers@1.2.1/utils", "@browserx/serialx": "../crates/serialx/serialx.ts" diff --git a/browser/src/engine/RenderingPipeline.ts b/browser/src/engine/RenderingPipeline.ts index 55ac0ed..6bee453 100644 --- a/browser/src/engine/RenderingPipeline.ts +++ b/browser/src/engine/RenderingPipeline.ts @@ -30,11 +30,16 @@ import { ImageDecoder } from "./rendering/ImageDecoder.ts"; import { WebGPUManager } from "./rendering/WebGPUManager.ts"; import { RenderingOrchestrator } from "./rendering/RenderingOrchestrator.ts"; +// Font engine for real TTF/OTF glyph rasterization +import { FontEngine } from "./rendering/text/FontEngine.ts"; + // Re-export sub-components for direct access export { ResourceFetcher } from "./rendering/ResourceFetcher.ts"; export { ImageDecoder } from "./rendering/ImageDecoder.ts"; export { WebGPUManager } from "./rendering/WebGPUManager.ts"; export { RenderingOrchestrator } from "./rendering/RenderingOrchestrator.ts"; +export { WindowRenderer } from "./rendering/WindowRenderer.ts"; +export type { WindowRendererConfig, DisplayMode, PresentFrameInfo } from "./rendering/WindowRenderer.ts"; /** * Rendering options @@ -49,6 +54,8 @@ export interface RenderingOptions { timeout?: number; signal?: AbortSignal; storageManager?: StorageManager; + /** Maximum node count before the legacy display list pass is skipped. Default: 5000 */ + displayListNodeThreshold?: number; } /** @@ -60,6 +67,8 @@ export interface RenderingResult { renderTree: RenderTree; layoutTree: LayoutBox; displayList: DisplayList; + /** True when the display list was not populated because the DOM exceeded the node threshold */ + displayListTruncated?: boolean; layerTree?: import("./rendering/paint/PaintLayer.ts").LayerTree; scriptExecutor?: ScriptExecutor; timing: RenderingTiming; @@ -138,14 +147,698 @@ export class RenderingPipelineError extends Error { } } +/** + * Shared FontEngine singleton — initialized lazily on first fillText() call. + * Provides real TTF/OTF glyph rasterization when system fonts are available. + */ +let sharedFontEngine: FontEngine | null = null; +let fontEngineInitPromise: Promise | null = null; +let fontEngineReady = false; + +function ensureFontEngine(): void { + if (fontEngineInitPromise) return; // already initializing + fontEngineInitPromise = (async () => { + const engine = new FontEngine(); + const wasmOk = await engine.initialize(); + if (wasmOk) { + const loaded = await engine.discoverSystemFonts(); + if (loaded > 0) { + sharedFontEngine = engine; + fontEngineReady = true; + } + } + })().catch(() => { + // Font engine init failed — bitmap fallback only + }); +} + +/** + * Proportional character width table (relative to fontSize). + * Matches TextLayout.CHAR_WIDTHS for consistency. + */ +const CHAR_WIDTHS: Record = { + "i": 0.28, "l": 0.28, "1": 0.33, "!": 0.30, "|": 0.25, + ".": 0.28, ",": 0.28, ":": 0.28, ";": 0.30, "'": 0.22, + "\"": 0.36, "`": 0.33, "j": 0.30, "f": 0.33, "r": 0.35, + "t": 0.35, " ": 0.28, + "a": 0.55, "b": 0.55, "c": 0.50, "d": 0.55, "e": 0.55, + "g": 0.55, "h": 0.55, "k": 0.50, "n": 0.55, "o": 0.55, + "p": 0.55, "q": 0.55, "s": 0.50, "u": 0.55, "v": 0.50, + "x": 0.50, "y": 0.50, "z": 0.50, + "0": 0.55, "2": 0.55, "3": 0.55, "4": 0.55, "5": 0.55, + "6": 0.55, "7": 0.50, "8": 0.55, "9": 0.55, + "m": 0.83, "w": 0.78, "M": 0.83, "W": 0.83, + "A": 0.67, "B": 0.67, "C": 0.67, "D": 0.72, "E": 0.61, + "F": 0.56, "G": 0.72, "H": 0.72, "I": 0.28, "J": 0.50, + "K": 0.67, "L": 0.56, "N": 0.72, "O": 0.72, "P": 0.61, + "Q": 0.72, "R": 0.67, "S": 0.61, "T": 0.61, "U": 0.72, + "V": 0.67, "X": 0.67, "Y": 0.67, "Z": 0.61, +}; + +/** + * Parse font size from a CSS font string (e.g. "bold 16px Arial" → 16). + */ +function parseFontSize(font: string): number { + const m = font.match(/(\d+(?:\.\d+)?)\s*px/); + return m ? parseFloat(m[1]) : 16; +} + +/** + * Parse font family from a CSS font string (e.g. "bold 16px Arial, sans-serif" → "Arial, sans-serif"). + */ +function parseFontFamily(font: string): string { + // Remove weight/style keywords and size, keep what's after "px " + const m = font.match(/\d+(?:\.\d+)?\s*px\s+(.*)/); + return m ? m[1].trim() : "sans-serif"; +} + +/** + * 5×7 bitmap font glyphs for printable ASCII (32-126). + * Each glyph is 7 rows of 5-bit bitmasks (MSB = leftmost pixel). + */ +const BITMAP_FONT: Record = { + // Space + 32: [0, 0, 0, 0, 0, 0, 0], + // ! + 33: [0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00000, 0b00100], + // " + 34: [0b01010, 0b01010, 0b01010, 0, 0, 0, 0], + // # + 35: [0b01010, 0b11111, 0b01010, 0b01010, 0b11111, 0b01010, 0], + // $ + 36: [0b00100, 0b01111, 0b10100, 0b01110, 0b00101, 0b11110, 0b00100], + // % + 37: [0b11001, 0b11010, 0b00100, 0b01000, 0b10110, 0b10011, 0], + // & + 38: [0b01100, 0b10010, 0b01100, 0b10101, 0b10010, 0b01101, 0], + // ' + 39: [0b00100, 0b00100, 0, 0, 0, 0, 0], + // ( + 40: [0b00010, 0b00100, 0b01000, 0b01000, 0b01000, 0b00100, 0b00010], + // ) + 41: [0b01000, 0b00100, 0b00010, 0b00010, 0b00010, 0b00100, 0b01000], + // * + 42: [0, 0b00100, 0b10101, 0b01110, 0b10101, 0b00100, 0], + // + + 43: [0, 0b00100, 0b00100, 0b11111, 0b00100, 0b00100, 0], + // , + 44: [0, 0, 0, 0, 0, 0b00100, 0b01000], + // - + 45: [0, 0, 0, 0b11111, 0, 0, 0], + // . + 46: [0, 0, 0, 0, 0, 0b00100, 0], + // / + 47: [0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0, 0], + // 0 + 48: [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110], + // 1 + 49: [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110], + // 2 + 50: [0b01110, 0b10001, 0b00001, 0b00110, 0b01000, 0b10000, 0b11111], + // 3 + 51: [0b01110, 0b10001, 0b00001, 0b00110, 0b00001, 0b10001, 0b01110], + // 4 + 52: [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010], + // 5 + 53: [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110], + // 6 + 54: [0b01110, 0b10000, 0b11110, 0b10001, 0b10001, 0b10001, 0b01110], + // 7 + 55: [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000], + // 8 + 56: [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110], + // 9 + 57: [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00001, 0b01110], + // : + 58: [0, 0, 0b00100, 0, 0b00100, 0, 0], + // ; + 59: [0, 0, 0b00100, 0, 0b00100, 0b01000, 0], + // < + 60: [0b00010, 0b00100, 0b01000, 0b10000, 0b01000, 0b00100, 0b00010], + // = + 61: [0, 0, 0b11111, 0, 0b11111, 0, 0], + // > + 62: [0b10000, 0b01000, 0b00100, 0b00010, 0b00100, 0b01000, 0b10000], + // ? + 63: [0b01110, 0b10001, 0b00010, 0b00100, 0b00100, 0, 0b00100], + // @ + 64: [0b01110, 0b10001, 0b10111, 0b10101, 0b10111, 0b10000, 0b01110], + // A-Z + 65: [0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001], + 66: [0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110], + 67: [0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110], + 68: [0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110], + 69: [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111], + 70: [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000], + 71: [0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01110], + 72: [0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001], + 73: [0b01110, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110], + 74: [0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100], + 75: [0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001], + 76: [0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111], + 77: [0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001], + 78: [0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001], + 79: [0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110], + 80: [0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000], + 81: [0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101], + 82: [0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001], + 83: [0b01110, 0b10001, 0b10000, 0b01110, 0b00001, 0b10001, 0b01110], + 84: [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100], + 85: [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110], + 86: [0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b01010, 0b00100], + 87: [0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001], + 88: [0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001], + 89: [0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100], + 90: [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111], + // [ \ ] + 91: [0b01110, 0b01000, 0b01000, 0b01000, 0b01000, 0b01000, 0b01110], + 92: [0b10000, 0b01000, 0b00100, 0b00010, 0b00001, 0, 0], + 93: [0b01110, 0b00010, 0b00010, 0b00010, 0b00010, 0b00010, 0b01110], + // ^ _ ` + 94: [0b00100, 0b01010, 0b10001, 0, 0, 0, 0], + 95: [0, 0, 0, 0, 0, 0, 0b11111], + 96: [0b01000, 0b00100, 0, 0, 0, 0, 0], + // a-z lowercase (g, j, p, q, y use DESCENDER_GLYPHS below for the tail) + 97: [0, 0, 0b01110, 0b00001, 0b01111, 0b10001, 0b01111], + 98: [0b10000, 0b10000, 0b11110, 0b10001, 0b10001, 0b10001, 0b11110], + 99: [0, 0, 0b01110, 0b10000, 0b10000, 0b10001, 0b01110], + 100: [0b00001, 0b00001, 0b01111, 0b10001, 0b10001, 0b10001, 0b01111], + 101: [0, 0, 0b01110, 0b10001, 0b11111, 0b10000, 0b01110], + 102: [0b00110, 0b01001, 0b01000, 0b11110, 0b01000, 0b01000, 0b01000], + 103: [0, 0, 0b01111, 0b10001, 0b10001, 0b01111, 0b00001], // row 7 = above baseline; descender continues below + 104: [0b10000, 0b10000, 0b10110, 0b11001, 0b10001, 0b10001, 0b10001], + 105: [0b00100, 0, 0b01100, 0b00100, 0b00100, 0b00100, 0b01110], + 106: [0b00010, 0, 0b00110, 0b00010, 0b00010, 0b00010, 0b00010], // descender continues + 107: [0b10000, 0b10000, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010], + 108: [0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110], + 109: [0, 0, 0b11010, 0b10101, 0b10101, 0b10001, 0b10001], + 110: [0, 0, 0b10110, 0b11001, 0b10001, 0b10001, 0b10001], + 111: [0, 0, 0b01110, 0b10001, 0b10001, 0b10001, 0b01110], + 112: [0, 0, 0b11110, 0b10001, 0b10001, 0b11110, 0b10000], // descender continues + 113: [0, 0, 0b01111, 0b10001, 0b10001, 0b01111, 0b00001], // descender continues + 114: [0, 0, 0b10110, 0b11001, 0b10000, 0b10000, 0b10000], + 115: [0, 0, 0b01111, 0b10000, 0b01110, 0b00001, 0b11110], + 116: [0b01000, 0b01000, 0b11110, 0b01000, 0b01000, 0b01001, 0b00110], + 117: [0, 0, 0b10001, 0b10001, 0b10001, 0b10011, 0b01101], + 118: [0, 0, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100], + 119: [0, 0, 0b10001, 0b10001, 0b10101, 0b10101, 0b01010], + 120: [0, 0, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001], + 121: [0, 0, 0b10001, 0b10001, 0b01001, 0b00110, 0b00100], // descender continues + 122: [0, 0, 0b11111, 0b00010, 0b00100, 0b01000, 0b11111], + // { | } + 123: [0b00010, 0b00100, 0b00100, 0b01000, 0b00100, 0b00100, 0b00010], + 124: [0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100], + 125: [0b01000, 0b00100, 0b00100, 0b00010, 0b00100, 0b00100, 0b01000], + // ~ + 126: [0, 0, 0b01000, 0b10101, 0b00010, 0, 0], +}; + +/** + * Descender extension rows for characters that extend below the baseline. + * These are 2 extra rows (rows 8-9) rendered below the 7-row glyph grid. + */ +const DESCENDER_ROWS: Record = { + 103: [0b01110, 0], // g: closing bowl below baseline + 106: [0b10010, 0b01100], // j: curve at bottom + 112: [0b10000, 0], // p: stem extends down + 113: [0b00001, 0], // q: stem extends down + 121: [0b01000, 0b10000], // y: tail extends down-left +}; + +/** + * Set a single pixel in the RGBA buffer with alpha blending. + */ +function blendPixel( + pixels: Uint8ClampedArray, + bufW: number, + bufH: number, + px: number, + py: number, + r: number, + g: number, + b: number, + a: number, +): void { + if (px < 0 || px >= bufW || py < 0 || py >= bufH) return; + const idx = (py * bufW + px) * 4; + if (a >= 0.999) { + // Opaque fast path + pixels[idx] = r; + pixels[idx + 1] = g; + pixels[idx + 2] = b; + pixels[idx + 3] = 255; + return; + } + if (a <= 0.001) return; + const dstA = pixels[idx + 3] / 255; + const outA = a + dstA * (1 - a); + if (outA > 0) { + pixels[idx] = Math.round((r * a + pixels[idx] * dstA * (1 - a)) / outA); + pixels[idx + 1] = Math.round((g * a + pixels[idx + 1] * dstA * (1 - a)) / outA); + pixels[idx + 2] = Math.round((b * a + pixels[idx + 2] * dstA * (1 - a)) / outA); + pixels[idx + 3] = Math.round(outA * 255); + } +} + +/** + * Render a bitmap character into an RGBA buffer. + * + * The 5×7 glyph is scaled to fit a target cell whose width = charAdvance + * and height = fontSize. This keeps characters proportional at any size. + * + * @param x Left edge of the character cell + * @param baseY Baseline y-coordinate (text draws upward from here) + */ +function renderBitmapChar( + pixels: Uint8ClampedArray, + bufW: number, + bufH: number, + code: number, + x: number, + baseY: number, + fontSize: number, + charAdvance: number, + color: [number, number, number, number], + alpha: number, +): void { + const glyph = BITMAP_FONT[code]; + if (!glyph) return; + + // Ascent: how far the glyph top sits above the baseline + const ascent = fontSize * 0.80; + const topY = baseY - ascent; + + // Glyph drawing area: 90% of advance width, centered horizontally + const glyphW = charAdvance * 0.90; + const offsetX = (charAdvance - glyphW) * 0.5; + + // Scale: map 5 glyph columns → glyphW, 7 rows → fontSize + const colW = glyphW / 5; + const rowH = fontSize / 7; + + const a = color[3] * alpha; + + // Render the 7 main glyph rows + for (let row = 0; row < 7; row++) { + const bits = glyph[row]; + if (bits === 0) continue; + const py0 = Math.round(topY + row * rowH); + const py1 = Math.max(py0 + 1, Math.round(topY + (row + 1) * rowH)); + for (let col = 0; col < 5; col++) { + if (!(bits & (1 << (4 - col)))) continue; + const px0 = Math.round(x + offsetX + col * colW); + const px1 = Math.max(px0 + 1, Math.round(x + offsetX + (col + 1) * colW)); + for (let py = py0; py < py1; py++) { + for (let px = px0; px < px1; px++) { + blendPixel(pixels, bufW, bufH, px, py, color[0], color[1], color[2], a); + } + } + } + } + + // Render descender rows (below baseline) if present + const descRows = DESCENDER_ROWS[code]; + if (descRows) { + for (let dr = 0; dr < 2; dr++) { + const bits = descRows[dr]; + if (bits === 0) continue; + const row = 7 + dr; + const py0 = Math.round(topY + row * rowH); + const py1 = Math.max(py0 + 1, Math.round(topY + (row + 1) * rowH)); + for (let col = 0; col < 5; col++) { + if (!(bits & (1 << (4 - col)))) continue; + const px0 = Math.round(x + offsetX + col * colW); + const px1 = Math.max(px0 + 1, Math.round(x + offsetX + (col + 1) * colW)); + for (let py = py0; py < py1; py++) { + for (let px = px0; px < px1; px++) { + blendPixel(pixels, bufW, bufH, px, py, color[0], color[1], color[2], a); + } + } + } + } + } +} + +/** + * Render a string of text as bitmap characters into an RGBA buffer. + * + * @param y The y position from the layout — treated as the TEXT BASELINE. + * The orchestrator passes layoutBox.y which is the box top, so the + * caller (fillText) adds the ascent offset before calling this. + */ +function renderBitmapText( + pixels: Uint8ClampedArray, + bufW: number, + bufH: number, + text: string, + x: number, + baseY: number, + fontSize: number, + color: [number, number, number, number], + alpha: number, + maxWidth?: number, +): void { + let curX = x; + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + const charRelWidth = CHAR_WIDTHS[text[i]] ?? 0.55; + const charAdvance = fontSize * charRelWidth; + + if (maxWidth !== undefined && (curX - x + charAdvance) > maxWidth) break; + + if (code !== 32) { // Skip rendering space characters + renderBitmapChar(pixels, bufW, bufH, code, curX, baseY, fontSize, charAdvance, color, alpha); + } + curX += charAdvance; + } +} + +/** + * Create a software Canvas2D context that holds an RGBA pixel buffer. + * Used when no real GPU/canvas is available (headless Deno). + */ +function createSoftwareContext2D( + canvas: { width: number; height: number }, +): import("../types/dom.ts").CanvasRenderingContext2D { + const w = canvas.width; + const h = canvas.height; + const pixels = new Uint8ClampedArray(w * h * 4); + // Fill white + for (let i = 0; i < pixels.length; i += 4) { + pixels[i] = 255; + pixels[i + 1] = 255; + pixels[i + 2] = 255; + pixels[i + 3] = 255; + } + const stateStack: Array<{ + fillStyle: string; + strokeStyle: string; + lineWidth: number; + font: string; + globalAlpha: number; + shadowOffsetX: number; + shadowOffsetY: number; + shadowBlur: number; + shadowColor: string; + globalCompositeOperation: string; + }> = []; + + const ctx: import("../types/dom.ts").CanvasRenderingContext2D = { + canvas: canvas as import("../types/dom.ts").HTMLCanvasElement, + fillStyle: "#000000", + strokeStyle: "#000000", + lineWidth: 1, + font: "16px sans-serif", + textAlign: "start", + textBaseline: "alphabetic", + globalAlpha: 1, + shadowOffsetX: 0, + shadowOffsetY: 0, + shadowBlur: 0, + shadowColor: "transparent", + globalCompositeOperation: "source-over", + + fillRect(x: number, y: number, rw: number, rh: number) { + const color = parseColor(String(ctx.fillStyle)); + const alpha = ctx.globalAlpha; + const x0 = Math.max(0, Math.round(x)); + const y0 = Math.max(0, Math.round(y)); + const x1 = Math.min(w, Math.round(x + rw)); + const y1 = Math.min(h, Math.round(y + rh)); + for (let py = y0; py < y1; py++) { + for (let px = x0; px < x1; px++) { + const idx = (py * w + px) * 4; + pixels[idx] = color[0]; + pixels[idx + 1] = color[1]; + pixels[idx + 2] = color[2]; + pixels[idx + 3] = Math.round(color[3] * alpha * 255); + } + } + }, + strokeRect(x: number, y: number, rw: number, rh: number) { + const lw = ctx.lineWidth; + ctx.fillRect(x, y, rw, lw); // top + ctx.fillRect(x, y + rh - lw, rw, lw); // bottom + ctx.fillRect(x, y, lw, rh); // left + ctx.fillRect(x + rw - lw, y, lw, rh); // right + }, + clearRect(x: number, y: number, rw: number, rh: number) { + const x0 = Math.max(0, Math.round(x)); + const y0 = Math.max(0, Math.round(y)); + const x1 = Math.min(w, Math.round(x + rw)); + const y1 = Math.min(h, Math.round(y + rh)); + for (let py = y0; py < y1; py++) { + for (let px = x0; px < x1; px++) { + const idx = (py * w + px) * 4; + pixels[idx] = 255; + pixels[idx + 1] = 255; + pixels[idx + 2] = 255; + pixels[idx + 3] = 255; + } + } + }, + fillText(text: string, x: number, y: number, maxWidth?: number) { + const color = parseColor(String(ctx.fillStyle)); + const alpha = ctx.globalAlpha; + const fontSize = parseFontSize(ctx.font); + const fontFamily = parseFontFamily(ctx.font); + + // Kick off async font engine init (non-blocking) + ensureFontEngine(); + + // Try real font rendering first + if (fontEngineReady && sharedFontEngine) { + const rendered = sharedFontEngine.renderText( + pixels, w, h, text, x, y, fontSize, fontFamily, color, alpha, + ); + if (rendered > 0) return; // real glyphs rendered successfully + } + + // Fallback: bitmap font + renderBitmapText(pixels, w, h, text, x, y, fontSize, color, alpha, maxWidth); + }, + strokeText(text: string, x: number, y: number, maxWidth?: number) { + const color = parseColor(String(ctx.strokeStyle)); + const alpha = ctx.globalAlpha; + const fontSize = parseFontSize(ctx.font); + const fontFamily = parseFontFamily(ctx.font); + + ensureFontEngine(); + + if (fontEngineReady && sharedFontEngine) { + const rendered = sharedFontEngine.renderText( + pixels, w, h, text, x, y, fontSize, fontFamily, color, alpha, + ); + if (rendered > 0) return; + } + + renderBitmapText(pixels, w, h, text, x, y, fontSize, color, alpha, maxWidth); + }, + measureText(text: string) { + const fontSize = parseFontSize(ctx.font); + const fontFamily = parseFontFamily(ctx.font); + + // Use real font metrics when available + if (fontEngineReady && sharedFontEngine) { + const width = sharedFontEngine.measureText(text, fontFamily, fontSize); + if (width > 0) return { width } as import("../types/dom.ts").TextMetrics; + } + + // Fallback: proportional estimates + let totalWidth = 0; + for (let i = 0; i < text.length; i++) { + const relWidth = CHAR_WIDTHS[text[i]] ?? 0.55; + totalWidth += fontSize * relWidth; + } + return { width: totalWidth } as import("../types/dom.ts").TextMetrics; + }, + drawImage(_image: unknown, _dx: number, _dy: number, _dw?: number, _dh?: number) {}, + save() { + stateStack.push({ + fillStyle: String(ctx.fillStyle), + strokeStyle: String(ctx.strokeStyle), + lineWidth: ctx.lineWidth, + font: ctx.font, + globalAlpha: ctx.globalAlpha, + shadowOffsetX: ctx.shadowOffsetX, + shadowOffsetY: ctx.shadowOffsetY, + shadowBlur: ctx.shadowBlur, + shadowColor: ctx.shadowColor, + globalCompositeOperation: ctx.globalCompositeOperation, + }); + }, + restore() { + const state = stateStack.pop(); + if (state) { + ctx.fillStyle = state.fillStyle; + ctx.strokeStyle = state.strokeStyle; + ctx.lineWidth = state.lineWidth; + ctx.font = state.font; + ctx.globalAlpha = state.globalAlpha; + ctx.shadowOffsetX = state.shadowOffsetX; + ctx.shadowOffsetY = state.shadowOffsetY; + ctx.shadowBlur = state.shadowBlur; + ctx.shadowColor = state.shadowColor; + ctx.globalCompositeOperation = state.globalCompositeOperation; + } + }, + scale(_x: number, _y: number) {}, + rotate(_angle: number) {}, + translate(_x: number, _y: number) {}, + transform(_a: number, _b: number, _c: number, _d: number, _e: number, _f: number) {}, + setTransform(_a: number, _b: number, _c: number, _d: number, _e: number, _f: number) {}, + getImageData(sx: number, sy: number, sw: number, sh: number) { + const data = new Uint8ClampedArray(sw * sh * 4); + for (let y = 0; y < sh; y++) { + for (let x = 0; x < sw; x++) { + const srcIdx = ((sy + y) * w + (sx + x)) * 4; + const dstIdx = (y * sw + x) * 4; + data[dstIdx] = pixels[srcIdx]; + data[dstIdx + 1] = pixels[srcIdx + 1]; + data[dstIdx + 2] = pixels[srcIdx + 2]; + data[dstIdx + 3] = pixels[srcIdx + 3]; + } + } + return { width: sw, height: sh, data } as import("../types/dom.ts").ImageData; + }, + putImageData(imageData: import("../types/dom.ts").ImageData, dx: number, dy: number) { + for (let y = 0; y < imageData.height; y++) { + for (let x = 0; x < imageData.width; x++) { + const srcIdx = (y * imageData.width + x) * 4; + const dstIdx = ((dy + y) * w + (dx + x)) * 4; + pixels[dstIdx] = imageData.data[srcIdx]; + pixels[dstIdx + 1] = imageData.data[srcIdx + 1]; + pixels[dstIdx + 2] = imageData.data[srcIdx + 2]; + pixels[dstIdx + 3] = imageData.data[srcIdx + 3]; + } + } + }, + rect(_x: number, _y: number, _w: number, _h: number) {}, + clip() {}, + beginPath() {}, + closePath() {}, + moveTo(_x: number, _y: number) {}, + lineTo(_x: number, _y: number) {}, + arc(_x: number, _y: number, _r: number, _sa: number, _ea: number, _cc?: boolean) {}, + arcTo(_x1: number, _y1: number, _x2: number, _y2: number, _r: number) {}, + quadraticCurveTo(_cpx: number, _cpy: number, _x: number, _y: number) {}, + bezierCurveTo(_cp1x: number, _cp1y: number, _cp2x: number, _cp2y: number, _x: number, _y: number) {}, + stroke() {}, + fill() {}, + }; + return ctx; +} + +/** + * Parse a CSS color string to [r, g, b, a] (0-255 for rgb, 0-1 for a) + */ +function parseColor(color: string): [number, number, number, number] { + if (color.startsWith("#")) { + const hex = color.slice(1); + if (hex.length === 3) { + return [ + parseInt(hex[0] + hex[0], 16), + parseInt(hex[1] + hex[1], 16), + parseInt(hex[2] + hex[2], 16), + 1, + ]; + } + return [ + parseInt(hex.slice(0, 2), 16), + parseInt(hex.slice(2, 4), 16), + parseInt(hex.slice(4, 6), 16), + 1, + ]; + } + const rgbaMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/); + if (rgbaMatch) { + return [ + parseInt(rgbaMatch[1]), + parseInt(rgbaMatch[2]), + parseInt(rgbaMatch[3]), + rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : 1, + ]; + } + // Named colors + const named: Record = { + black: [0, 0, 0, 1], + white: [255, 255, 255, 1], + red: [255, 0, 0, 1], + green: [0, 128, 0, 1], + blue: [0, 0, 255, 1], + transparent: [0, 0, 0, 0], + gray: [128, 128, 128, 1], + grey: [128, 128, 128, 1], + silver: [192, 192, 192, 1], + maroon: [128, 0, 0, 1], + navy: [0, 0, 128, 1], + teal: [0, 128, 128, 1], + aqua: [0, 255, 255, 1], + cyan: [0, 255, 255, 1], + lime: [0, 255, 0, 1], + olive: [128, 128, 0, 1], + purple: [128, 0, 128, 1], + fuchsia: [255, 0, 255, 1], + magenta: [255, 0, 255, 1], + yellow: [255, 255, 0, 1], + orange: [255, 165, 0, 1], + orangered: [255, 69, 0, 1], + brown: [165, 42, 42, 1], + pink: [255, 192, 203, 1], + coral: [255, 127, 80, 1], + tomato: [255, 99, 71, 1], + gold: [255, 215, 0, 1], + darkgray: [169, 169, 169, 1], + darkgrey: [169, 169, 169, 1], + lightgray: [211, 211, 211, 1], + lightgrey: [211, 211, 211, 1], + dimgray: [105, 105, 105, 1], + dimgrey: [105, 105, 105, 1], + darkred: [139, 0, 0, 1], + darkblue: [0, 0, 139, 1], + darkgreen: [0, 100, 0, 1], + indianred: [205, 92, 92, 1], + crimson: [220, 20, 60, 1], + steelblue: [70, 130, 180, 1], + dodgerblue: [30, 144, 255, 1], + royalblue: [65, 105, 225, 1], + cornflowerblue: [100, 149, 237, 1], + slategray: [112, 128, 144, 1], + slategrey: [112, 128, 144, 1], + whitesmoke: [245, 245, 245, 1], + gainsboro: [220, 220, 220, 1], + ghostwhite: [248, 248, 255, 1], + aliceblue: [240, 248, 255, 1], + lavender: [230, 230, 250, 1], + linen: [250, 240, 230, 1], + ivory: [255, 255, 240, 1], + snow: [255, 250, 250, 1], + seashell: [255, 245, 238, 1], + beige: [245, 245, 220, 1], + wheat: [245, 222, 179, 1], + tan: [210, 180, 140, 1], + khaki: [240, 230, 140, 1], + }; + return named[color.toLowerCase()] ?? [0, 0, 0, 1]; +} + /** * Create OffscreenCanvas abstraction for Deno runtime */ function createOffscreenCanvas(width: number, height: number): OffscreenCanvas { - return { + let cachedCtx: import("../types/dom.ts").CanvasRenderingContext2D | null = null; + const canvas = { width, height, - getContext: (_contextId: string) => null, + getContext: (contextId: string) => { + if (contextId === "2d") { + if (!cachedCtx) { + cachedCtx = createSoftwareContext2D(canvas as unknown as { width: number; height: number }); + } + return cachedCtx; + } + return null; + }, convertToBlob: async () => new Blob(), transferToImageBitmap: () => ({ width, @@ -153,6 +846,7 @@ function createOffscreenCanvas(width: number, height: number): OffscreenCanvas { close: () => {}, } as ImageBitmap), } as OffscreenCanvas; + return canvas; } /** @@ -243,7 +937,33 @@ export class RenderingPipeline { // Render // ======================================================================== + /** + * Initialize the font engine and discover system fonts. + * Call before render() for best text quality on first frame. + * Automatically called lazily if not called explicitly. + */ + async initializeFonts(): Promise { + ensureFontEngine(); + if (fontEngineInitPromise) { + await fontEngineInitPromise; + } + return fontEngineReady; + } + + /** + * Get the shared font engine instance (null if not yet initialized or failed). + */ + getFontEngine(): FontEngine | null { + return sharedFontEngine; + } + async render(url: string | URL, options: RenderingOptions = {}): Promise { + // Ensure font engine is initialized before rendering + ensureFontEngine(); + if (fontEngineInitPromise) { + await fontEngineInitPromise; + } + const result = await this.orchestrator.render(url, options, this.requestPipeline); this.lastRenderResult = result; return result; @@ -339,6 +1059,18 @@ export class RenderingPipeline { return this.compositor; } + getOrchestrator(): RenderingOrchestrator { + return this.orchestrator; + } + + /** + * Set a WindowRenderer on the orchestrator. + * When set, each render() call will push pixels to the renderer. + */ + setWindowRenderer(renderer: import("./rendering/WindowRenderer.ts").WindowRenderer | null): void { + this.orchestrator.setWindowRenderer(renderer); + } + // ======================================================================== // Cleanup // ======================================================================== diff --git a/browser/src/engine/RequestPipeline.ts b/browser/src/engine/RequestPipeline.ts index a9c3d39..efdc78e 100644 --- a/browser/src/engine/RequestPipeline.ts +++ b/browser/src/engine/RequestPipeline.ts @@ -67,7 +67,7 @@ export class RequestPipelineError extends Error { constructor( message: string, public readonly stage: string, - public readonly cause?: Error, + public override readonly cause?: Error, ) { super(message); this.name = "RequestPipelineError"; @@ -163,42 +163,66 @@ export class RequestPipeline { const startTime = Date.now(); const timeout = options.timeout ?? 30000; // Default 30 second timeout - // Wrap the request in a timeout - const requestPromise = this.doRequest(url, options, startTime); + // Create an internal AbortController so timeout/abort actually cancels doRequest + const internalController = new AbortController(); + const internalSignal = internalController.signal; - const racers: Promise[] = [requestPromise]; - - let timeoutId: number | undefined; - if (timeout > 0) { - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - reject( - new RequestPipelineError( - `Request timed out after ${timeout}ms`, - "timeout", - ), + // If the caller provided a signal, forward its abort to our internal controller + let externalAbortHandler: (() => void) | undefined; + if (options.signal) { + if (options.signal.aborted) { + internalController.abort(options.signal.reason); + } else { + externalAbortHandler = () => { + internalController.abort( + options.signal!.reason || new RequestPipelineError("Request aborted", "aborted"), ); - }, timeout) as unknown as number; - }); - racers.push(timeoutPromise); + }; + options.signal.addEventListener("abort", externalAbortHandler, { once: true }); + } } - // Add abort signal to the race if provided - if (options.signal) { - const abortPromise = new Promise((_, reject) => { - options.signal!.addEventListener("abort", () => { - reject(options.signal!.reason || new RequestPipelineError("Request aborted", "aborted")); - }, { once: true }); - }); - racers.push(abortPromise); - } + // Pass internal signal to doRequest so checkpoints can observe cancellation + const internalOptions = { ...options, signal: internalSignal }; + const requestPromise = this.doRequest(url, internalOptions, startTime); - try { - const result = await Promise.race(racers); + let timeoutId: number | undefined; + + const cleanup = () => { if (timeoutId !== undefined) clearTimeout(timeoutId); + // Remove the forwarding listener to release the closure + if (externalAbortHandler && options.signal) { + options.signal.removeEventListener("abort", externalAbortHandler); + } + }; + + try { + if (timeout > 0) { + const result = await Promise.race([ + requestPromise, + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + const err = new RequestPipelineError( + `Request timed out after ${timeout}ms`, + "timeout", + ); + internalController.abort(err); + reject(err); + }, timeout) as unknown as number; + }), + ]); + cleanup(); + return result; + } + const result = await requestPromise; + cleanup(); return result; } catch (err) { - if (timeoutId !== undefined) clearTimeout(timeoutId); + cleanup(); + // Ensure doRequest is cancelled on any rejection + if (!internalSignal.aborted) { + internalController.abort(err); + } throw err; } } @@ -300,7 +324,7 @@ export class RequestPipeline { // Pass hostname for TLS SNI - must be hostname, not IP address this.emitStage("tcp-connection", "TCP Connection", "running", Date.now()); const connStart = Date.now(); - const connection = await this.connectionPool.acquire( + const connection = await this.connectionManager.acquire( targetIP, port as Port, isSecure, @@ -355,7 +379,9 @@ export class RequestPipeline { // 5. Receive HTTP response this.emitStage("http-receive", "HTTP Receive", "running", Date.now()); const respStart = Date.now(); - const chunks: Uint8Array[] = []; + const maxResponseSize = 100 * 1024 * 1024; // 100MB max + // Use a single growing buffer to avoid O(n²) concatenation per chunk + let receiveBuffer = new Uint8Array(65536); // Initial 64KB, grows as needed let totalBytes = 0; let headersComplete = false; let isChunked = false; @@ -383,22 +409,30 @@ export class RequestPipeline { break; // Connection closed } - chunks.push(chunk.slice(0, bytesRead)); + // Grow buffer if needed (double until sufficient) + while (totalBytes + bytesRead > receiveBuffer.length) { + const newBuffer = new Uint8Array(receiveBuffer.length * 2); + newBuffer.set(receiveBuffer); + receiveBuffer = newBuffer; + } + receiveBuffer.set(chunk.subarray(0, bytesRead), totalBytes); totalBytes += bytesRead; // Check if headers are complete if (!headersComplete) { - const partialResponse = this.concatChunks(chunks, totalBytes); - // Search for \r\n\r\n (0x0d 0x0a 0x0d 0x0a) in raw bytes to avoid - // byte/char offset mismatch with multi-byte UTF-8 in headers - const headerEndByteIndex = this.findHeaderEnd(partialResponse); + // Search for \r\n\r\n only in the region where new data was appended, + // including 3 bytes before the new data to catch boundary splits + const searchStart = Math.max(0, totalBytes - bytesRead - 3); + const headerEndByteIndex = this.findHeaderEndInRange( + receiveBuffer, searchStart, totalBytes, + ); if (headerEndByteIndex !== -1) { headersComplete = true; bodyStartIndex = headerEndByteIndex + 4; // Decode only the header portion for text-based checks - const text = decoder.decode(partialResponse.slice(0, headerEndByteIndex)); + const text = decoder.decode(receiveBuffer.subarray(0, headerEndByteIndex)); // Check for chunked encoding or content-length const lowerText = text.toLowerCase(); if (lowerText.includes("transfer-encoding: chunked")) { @@ -420,8 +454,7 @@ export class RequestPipeline { if (Number.isNaN(parsedLength) || parsedLength < 0) { throw new Error(`Invalid Content-Length value: ${clMatch[1]}`); } - // Validate Content-Length doesn't exceed max response size (10MB) - const maxResponseSize = 10 * 1024 * 1024; + // Validate Content-Length doesn't exceed max response size (100MB) if (parsedLength > maxResponseSize) { throw new Error( `Content-Length ${parsedLength} exceeds maximum allowed size of ${maxResponseSize} bytes`, @@ -434,22 +467,17 @@ export class RequestPipeline { // Check if response is complete if (headersComplete) { - const partialResponse = this.concatChunks(chunks, totalBytes); - if (isChunked) { - // Check for chunked terminator: 0\r\n followed by optional trailers and final \r\n - // RFC 7230 Section 4.1: chunked-body = *chunk last-chunk trailer-part CRLF - // last-chunk = "0" *( ";" chunk-ext ) CRLF - // trailer-part = *( header-field CRLF ) - const text = decoder.decode(partialResponse.slice(bodyStartIndex)); - // Find the zero-length chunk (can be "0\r\n" or "0;ext\r\n") - const zeroChunkMatch = text.match(/\r\n0(?:;[^\r\n]*)?\r\n/); - if (zeroChunkMatch) { - // Zero-length chunk found - check if the message ends with \r\n\r\n - // (either no trailers: "0\r\n\r\n" or trailers followed by "\r\n\r\n") - if (text.endsWith("\r\n\r\n")) { - break; // Chunked response complete - } + // Check for chunked terminator in the tail region of the body. + // The terminator is 7 bytes: \r\n 0 \r\n \r\n — only need to scan + // the newly received data plus a small overlap. + const bodyStart = bodyStartIndex; + const searchFrom = Math.max(bodyStart, totalBytes - bytesRead - 6); + const foundTerminator = this.findChunkedTerminator( + receiveBuffer, searchFrom, totalBytes, bodyStart, + ); + if (foundTerminator) { + break; // Chunked response complete } } else if (contentLength >= 0) { const bodyBytes = totalBytes - bodyStartIndex; @@ -457,16 +485,14 @@ export class RequestPipeline { break; // Content-Length reached } } else { - // No content-length or chunked - read a bit more then stop - // This handles responses with no body - if (totalBytes > bodyStartIndex) { - break; - } + // No content-length and not chunked — Connection: close semantics. + // Read until the server closes the connection (read returns null/0). + // The loop continues; we break when read() returns null below. } } - // Safety limit - should match Content-Length validation (10MB) - if (totalBytes > 10 * 1024 * 1024) { // 10MB max + // Safety limit - should match Content-Length validation (100MB) + if (totalBytes > maxResponseSize) { break; } } @@ -475,15 +501,38 @@ export class RequestPipeline { throw new Error("No response received from server"); } - const responseData = this.concatChunks(chunks, totalBytes); + const responseData = receiveBuffer.subarray(0, totalBytes); // Parse response const response = this.parseResponse(responseData as ByteBuffer, request.id); response.fromCache = false; timing.download = Date.now() - respStart - (timing.firstByte || 0); + // Decompress body if Content-Encoding is set + const contentEncoding = response.headers.get("content-encoding")?.toLowerCase(); + if (contentEncoding && response.body && response.body.byteLength > 0) { + try { + let decompressed: Uint8Array | null = null; + if (contentEncoding === "gzip" || contentEncoding === "x-gzip") { + decompressed = await this.decompressBody(response.body, "gzip"); + } else if (contentEncoding === "deflate") { + decompressed = await this.decompressBody(response.body, "deflate"); + } + if (decompressed) { + response.body = decompressed as ByteBuffer; + // Remove content-encoding since we've decoded it + response.headers.delete("content-encoding"); + // Update content-length to reflect decoded size + response.headers.set("content-length", String(decompressed.byteLength)); + } + } catch { + // If decompression fails, keep the original body — + // server may have lied about encoding, or body may not actually be compressed + } + } + // Release connection back to pool - await this.connectionPool.release(connection); + await this.connectionManager.release(connection); connectionReleased = true; // 6. Store in cache (if cacheable) @@ -533,6 +582,16 @@ export class RequestPipeline { maxRedirects: maxRedirects - 1, _visitedUrls: visitedUrls, }; + + // Per RFC 7231: 303 See Other always redirects as GET + // 302 Found historically treated as GET (de facto standard) + if (response.statusCode === 303 || response.statusCode === 302) { + redirectOptions.method = "GET"; + // Remove body for GET requests + delete redirectOptions.body; + } + // 307/308 preserve the original method + return await this.request(redirectUrl, redirectOptions); } } @@ -559,7 +618,7 @@ export class RequestPipeline { // Ensure connection is always released back to the pool, even on error if (!connectionReleased) { try { - await this.connectionPool.release(connection); + await this.connectionManager.release(connection); } catch { // Ignore release errors during error handling } @@ -623,21 +682,49 @@ export class RequestPipeline { * Resolve DNS with caching */ private async resolveDNS(hostname: string): Promise { + // IP address passthrough — skip DNS for IPv4/IPv6 literals + if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname) || hostname.startsWith("[") || hostname.includes("::")) { + return [hostname.replace(/^\[|\]$/g, "")]; + } + // Check cache first const cached = this.dnsCache.get(hostname); if (cached && cached.addresses.length > 0) { return cached.addresses; } - // Resolve via DNS + // Resolve via DNS — try custom resolver first, then Deno.resolveDns() fallback try { const result: DNSResult = await this.dnsResolver.resolve(hostname); - // Store in cache + // Store in cache — cache under both the queried hostname and the + // result hostname (which may differ due to CNAME following) this.dnsCache.set(result); + if (result.hostname !== hostname) { + this.dnsCache.set({ ...result, hostname }); + } return result.addresses; } catch (error) { + // Fallback to Deno's built-in DNS resolver + try { + if (typeof Deno !== "undefined" && Deno.resolveDns) { + const addresses = await Deno.resolveDns(hostname, "A"); + if (addresses.length > 0) { + const result: DNSResult = { + hostname, + addresses, + ttl: 300, + timestamp: Date.now(), + }; + this.dnsCache.set(result); + return addresses; + } + } + } catch { + // Deno.resolveDns also failed — throw original error + } + throw new RequestPipelineError( `DNS resolution failed for ${hostname}: ${ error instanceof Error ? error.message : String(error) @@ -690,7 +777,14 @@ export class RequestPipeline { * Returns the byte index of the first \r in the sequence, or -1 if not found. */ private findHeaderEnd(data: Uint8Array): number { - for (let i = 0; i <= data.length - 4; i++) { + return this.findHeaderEndInRange(data, 0, data.length); + } + + /** + * Search for \r\n\r\n within a specific byte range of a buffer + */ + private findHeaderEndInRange(data: Uint8Array, start: number, end: number): number { + for (let i = start; i <= end - 4; i++) { if ( data[i] === 0x0d && data[i + 1] === 0x0a && @@ -703,6 +797,34 @@ export class RequestPipeline { return -1; } + /** + * Search for chunked transfer terminator in a byte range. + * Looks for \r\n0\r\n\r\n (7 bytes) or 0\r\n\r\n at body start (5 bytes). + */ + private findChunkedTerminator( + data: Uint8Array, searchFrom: number, end: number, bodyStart: number, + ): boolean { + // Check for mid-body terminator: \r\n 0 \r\n \r\n + for (let bi = searchFrom; bi <= end - 7; bi++) { + if ( + data[bi] === 0x0D && data[bi + 1] === 0x0A && + data[bi + 2] === 0x30 && + data[bi + 3] === 0x0D && data[bi + 4] === 0x0A && + data[bi + 5] === 0x0D && data[bi + 6] === 0x0A + ) { + return true; + } + } + // Check if body starts with "0\r\n\r\n" (empty body, first chunk is last) + if (end - bodyStart >= 5 && + data[bodyStart] === 0x30 && data[bodyStart + 1] === 0x0D && + data[bodyStart + 2] === 0x0A && data[bodyStart + 3] === 0x0D && + data[bodyStart + 4] === 0x0A) { + return true; + } + return false; + } + private concatChunks(chunks: Uint8Array[], totalBytes: number): Uint8Array { const result = new Uint8Array(totalBytes); let offset = 0; @@ -749,6 +871,7 @@ export class RequestPipeline { headers.set("host", url.host); headers.set("user-agent", "BrowserX/1.0"); headers.set("accept", "*/*"); + headers.set("accept-encoding", "gzip, deflate"); headers.set("connection", "keep-alive"); // Add custom headers @@ -811,6 +934,41 @@ export class RequestPipeline { return headerData; } + /** + * Decompress a response body using DecompressionStream (Web Streams API). + */ + private async decompressBody( + body: ByteBuffer, + format: "gzip" | "deflate", + ): Promise { + const ds = new DecompressionStream(format); + const writer = ds.writable.getWriter(); + const reader = ds.readable.getReader(); + + // Write compressed data and close + writer.write(body); + writer.close(); + + // Read all decompressed chunks + const chunks: Uint8Array[] = []; + let totalLen = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + totalLen += value.byteLength; + } + + // Concat into single buffer + const result = new Uint8Array(totalLen); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result; + } + /** * Parse HTTP response from bytes */ @@ -942,7 +1100,7 @@ export class RequestPipeline { */ async close(): Promise { this.dnsCache.dispose(); - await this.connectionManager.closeAll(); + await this.connectionPool.destroy(); } /** diff --git a/browser/src/engine/javascript/DOMBindings.ts b/browser/src/engine/javascript/DOMBindings.ts index c49f277..7ee7cbd 100644 --- a/browser/src/engine/javascript/DOMBindings.ts +++ b/browser/src/engine/javascript/DOMBindings.ts @@ -2917,14 +2917,14 @@ export class DOMBindings { toggle: () => false, replace: () => false, }; - cloned.getAttribute = (name: string) => cloned.attributes.get(name) ?? null; + cloned.getAttribute = (name: string) => cloned.attributes!.get(name) ?? null; cloned.setAttribute = (name: string, value: string) => { - cloned.attributes.set(name, value); + cloned.attributes!.set(name, value); if (name === "id") cloned.id = value; if (name === "class") cloned.className = value; }; - cloned.removeAttribute = (name: string) => cloned.attributes.delete(name); - cloned.hasAttribute = (name: string) => cloned.attributes.has(name); + cloned.removeAttribute = (name: string) => cloned.attributes!.delete(name); + cloned.hasAttribute = (name: string) => cloned.attributes!.has(name); cloned.querySelector = (sel: string) => this.querySelector(cloned, sel); cloned.querySelectorAll = (sel: string) => this.querySelectorAll(cloned, sel); cloned.getElementsByTagName = (tag: string) => this.getElementsByTagName(cloned, tag); diff --git a/browser/src/engine/javascript/IgnitionInterpreter.ts b/browser/src/engine/javascript/IgnitionInterpreter.ts index 4c767f6..3b0536c 100644 --- a/browser/src/engine/javascript/IgnitionInterpreter.ts +++ b/browser/src/engine/javascript/IgnitionInterpreter.ts @@ -110,6 +110,19 @@ export class IgnitionInterpreter { private cacheHits = 0; private cacheMisses = 0; + /** Exception handler stack for try/catch */ + private exceptionHandlers: Array<{ + catchOffset: number; + frameDepth: number; + bytecodeRef: Uint8Array; + }> = []; + + /** Caught exception value (set by THROW, read by SET_CATCH_PARAM) */ + private caughtException: JSValue = createUndefined(); + + /** Pending 'this' binding for next method call (set by STA_CONTEXT_SLOT "this", consumed by CALL) */ + private pendingThisBinding: JSValue | null = null; + constructor(heap: V8Heap | null = null) { this.accumulator = createUndefined(); this.registers = []; @@ -165,7 +178,19 @@ export class IgnitionInterpreter { `Script exceeded instruction budget of ${this.maxInstructions} instructions (possible infinite loop)`, ); } - this.executeInstruction(bytecode); + try { + this.executeInstruction(bytecode); + } catch (e) { + // If we have a JS-level exception handler, route to it + if (this.exceptionHandlers.length > 0) { + const msg = e instanceof Error ? e.message : String(e); + this.throwException(createString(msg)); + this.stats.instructionsExecuted++; + instructionsThisExecution++; + continue; + } + throw e; + } this.stats.instructionsExecuted++; instructionsThisExecution++; } @@ -363,6 +388,28 @@ export class IgnitionInterpreter { }); break; + // Exception handling + case Opcode.TRY_START: + this.executeTRY_START(bytecode); + break; + case Opcode.TRY_END: + this.executeTRY_END(); + break; + case Opcode.THROW: + this.executeTHROW(bytecode); + break; + case Opcode.SET_CATCH_PARAM: + this.executeSET_CATCH_PARAM(bytecode); + break; + + // Operators + case Opcode.TYPEOF: + this.executeTYPEOF(); + break; + case Opcode.INSTANCEOF: + this.executeINSTANCEOF(bytecode); + break; + default: throw new Error(`Unknown opcode: ${opcode}`); } @@ -720,17 +767,24 @@ export class IgnitionInterpreter { savedAccumulator: this.accumulator, }); - // Create function execution context + // Create function execution context with optional 'this' from method call + const thisValue = this.pendingThisBinding || createUndefined(); + this.pendingThisBinding = null; const outer = this.currentContext.lexicalEnvironment; const realm = this.currentContext.realm!; const funcCtx = createFunctionExecutionContext( func, - createUndefined(), + thisValue, undefined, outer, realm, ); + // Bind 'this' in function context + if (thisValue.type !== JSValueType.UNDEFINED) { + funcCtx.lexicalEnvironment.bindings.set("this", thisValue); + } + // Bind parameters if (funcNode) { for (let i = 0; i < funcNode.params.length; i++) { @@ -807,7 +861,15 @@ export class IgnitionInterpreter { } // Create new object with constructor's prototype - const newObj = createObject(fn.prototype || null); + // Check both fn.prototype field and fn.properties.get("prototype") + let ctorProto = fn.prototype || null; + if (!ctorProto && fn.properties?.has("prototype")) { + const protoProp = fn.properties.get("prototype"); + if (protoProp && protoProp.type === JSValueType.OBJECT) { + ctorProto = protoProp.value as import("./JSValue.ts").JSObject; + } + } + const newObj = createObject(ctorProto); if (newObj.type === JSValueType.OBJECT) { newObj.value.constructor = fn; } @@ -894,9 +956,8 @@ export class IgnitionInterpreter { if (obj.type === JSValueType.OBJECT || obj.type === JSValueType.FUNCTION) { // Inline cache check: same object identity + same property name const cached = this.propertyCache.get(cacheKey); - if (cached && cached.name === name && cached.objectRef.deref() === obj.value) { - // Verify the property still exists with same value (monomorphic guard) - const current = obj.value.properties.get(name); + if (cached && cached.name === name && cached.objectRef.deref() === (obj.value as unknown as Record)) { + const current = (obj.value as import("./JSValue.ts").JSObject).properties.get(name); if (current !== undefined && current === cached.value) { this.accumulator = cached.value; this.cacheHits++; @@ -953,7 +1014,7 @@ export class IgnitionInterpreter { // Invalidate any inline cache entries that reference this object + property for (const [key, entry] of this.propertyCache) { - if (entry.name === name && entry.objectRef.deref() === obj.value) { + if (entry.name === name && entry.objectRef.deref() === (obj.value as unknown as Record)) { this.propertyCache.delete(key); } } @@ -1076,6 +1137,11 @@ export class IgnitionInterpreter { const nameIndex = this.readOperand(bytecode); const name = this.constantPool[nameIndex] as string; + // Track 'this' for method calls + if (name === "this") { + this.pendingThisBinding = this.accumulator; + } + // Set in current execution context's environment chain const success = setIdentifierReference( this.currentContext.lexicalEnvironment, @@ -1309,6 +1375,150 @@ export class IgnitionInterpreter { isExecuting(): boolean { return this.isRunning; } + + // ====================================================================== + // Exception handling opcodes + // ====================================================================== + + /** + * TRY_START - Register exception handler + * Operand: catch handler offset (absolute bytecode position) + */ + private executeTRY_START(bytecode: Uint8Array): void { + const catchOffset = this.readOperand(bytecode); + this.exceptionHandlers.push({ + catchOffset, + frameDepth: this.frameStack.length, + bytecodeRef: bytecode, + }); + } + + /** + * TRY_END - Remove current exception handler + */ + private executeTRY_END(): void { + if (this.exceptionHandlers.length > 0) { + this.exceptionHandlers.pop(); + } + } + + /** + * THROW - Throw exception from accumulator + * If a handler is registered, jump to catch; otherwise rethrow as JS Error + */ + private executeTHROW(_bytecode: Uint8Array): void { + this.throwException(this.accumulator); + } + + /** + * Internal: route exception to nearest catch handler or rethrow + */ + private throwException(value: JSValue): void { + if (this.exceptionHandlers.length > 0) { + const handler = this.exceptionHandlers.pop()!; + // Unwind frames if we're deeper than when the handler was registered + while (this.frameStack.length > handler.frameDepth) { + const frame = this.frameStack.pop()!; + this.registers = frame.savedRegisters; + this.constantPool = frame.function.constantPool; + this.callStack.pop(); + const prev = this.callStack.current(); + if (prev) this.currentContext = prev; + } + this.caughtException = value; + this.programCounter = handler.catchOffset; + } else { + // No handler — convert to real throw + const msg = value.type === JSValueType.STRING + ? value.value as string + : value.type === JSValueType.OBJECT && (value.value as unknown as Record)?.message + ? String((value.value as unknown as Record).message) + : "value" in value ? String(value.value) : "undefined"; + throw new Error(msg); + } + } + + /** + * SET_CATCH_PARAM - Store caught exception into a variable name + * Operand: constant pool index of the variable name + */ + private executeSET_CATCH_PARAM(bytecode: Uint8Array): void { + const nameIndex = this.readOperand(bytecode); + const name = this.constantPool[nameIndex] as string; + // Store exception in current scope + this.currentContext.lexicalEnvironment.bindings.set(name, this.caughtException); + // Also put it in the accumulator for convenience + this.accumulator = this.caughtException; + } + + // ====================================================================== + // Operator opcodes + // ====================================================================== + + /** + * TYPEOF - typeof accumulator → string in accumulator + */ + private executeTYPEOF(): void { + const val = this.accumulator; + let result: string; + switch (val.type) { + case JSValueType.UNDEFINED: + result = "undefined"; + break; + case JSValueType.NULL: + result = "object"; // typeof null === "object" per spec + break; + case JSValueType.BOOLEAN: + result = "boolean"; + break; + case JSValueType.NUMBER: + result = "number"; + break; + case JSValueType.STRING: + result = "string"; + break; + case JSValueType.FUNCTION: + result = "function"; + break; + case JSValueType.SYMBOL: + result = "symbol"; + break; + case JSValueType.BIGINT: + result = "bigint"; + break; + default: + result = "object"; + break; + } + this.accumulator = createString(result); + } + + /** + * INSTANCEOF - accumulator instanceof register → boolean in accumulator + * Operand: register index containing the constructor + */ + private executeINSTANCEOF(bytecode: Uint8Array): void { + const registerIndex = this.readOperand(bytecode); + const obj = this.accumulator; + const ctor = this.registers[registerIndex] || createUndefined(); + + // Walk prototype chain + if (obj.type === JSValueType.OBJECT && ctor.type === JSValueType.FUNCTION) { + const ctorFn = ctor.value as JSFunction; + const ctorPrototype = ctorFn.properties?.get("prototype"); + if (ctorPrototype) { + let proto = (obj.value as unknown as Record)?.__proto__ as JSValue | undefined; + while (proto && proto.type !== JSValueType.NULL && proto.type !== JSValueType.UNDEFINED) { + if (proto === ctorPrototype) { + this.accumulator = createBoolean(true); + return; + } + proto = "value" in proto ? (proto.value as unknown as Record)?.__proto__ as JSValue | undefined : undefined; + } + } + } + this.accumulator = createBoolean(false); + } } /** diff --git a/browser/src/engine/javascript/ScriptExecutor.ts b/browser/src/engine/javascript/ScriptExecutor.ts index 3200b50..c594eaf 100644 --- a/browser/src/engine/javascript/ScriptExecutor.ts +++ b/browser/src/engine/javascript/ScriptExecutor.ts @@ -424,7 +424,7 @@ export class ScriptExecutor { } // Fallback: check for documentElement - if (doc.documentElement) { + if ((doc as unknown as { documentElement?: unknown }).documentElement) { return; } diff --git a/browser/src/engine/javascript/V8Compiler.ts b/browser/src/engine/javascript/V8Compiler.ts index 6a2c468..a628ee1 100644 --- a/browser/src/engine/javascript/V8Compiler.ts +++ b/browser/src/engine/javascript/V8Compiler.ts @@ -37,6 +37,31 @@ export enum JSJSTokenType { CONTINUE = "continue", THIS = "this", NEW = "new", + CLASS = "class", + EXTENDS = "extends", + SUPER = "super", + STATIC = "static", + ASYNC = "async", + AWAIT = "await", + TRY = "try", + CATCH = "catch", + FINALLY = "finally", + THROW = "throw", + IMPORT = "import", + EXPORT = "export", + FROM = "from", + TYPEOF = "typeof", + INSTANCEOF = "instanceof", + IN = "in", + DELETE = "delete", + VOID = "void", + YIELD = "yield", + SWITCH = "switch", + CASE = "case", + DEFAULT = "default", + DO = "do", + TEMPLATE_LITERAL = "template_literal", + SPREAD = "...", // Operators PLUS = "+", @@ -116,6 +141,22 @@ export enum ASTNodeType { BLOCK_STATEMENT = "BlockStatement", BREAK_STATEMENT = "BreakStatement", CONTINUE_STATEMENT = "ContinueStatement", + CLASS_DECLARATION = "ClassDeclaration", + CLASS_EXPRESSION = "ClassExpression", + METHOD_DEFINITION = "MethodDefinition", + TRY_STATEMENT = "TryStatement", + CATCH_CLAUSE = "CatchClause", + THROW_STATEMENT = "ThrowStatement", + AWAIT_EXPRESSION = "AwaitExpression", + IMPORT_DECLARATION = "ImportDeclaration", + EXPORT_DECLARATION = "ExportDeclaration", + SWITCH_STATEMENT = "SwitchStatement", + SWITCH_CASE = "SwitchCase", + DO_WHILE_STATEMENT = "DoWhileStatement", + TEMPLATE_LITERAL = "TemplateLiteral", + SPREAD_ELEMENT = "SpreadElement", + TYPEOF_EXPRESSION = "TypeofExpression", + INSTANCEOF_EXPRESSION = "InstanceofExpression", } /** @@ -329,6 +370,89 @@ export interface ProgramNode extends ASTNode { body: ASTNode[]; } +/** + * Class declaration node + */ +export interface ClassDeclarationNode extends ASTNode { + type: ASTNodeType.CLASS_DECLARATION; + id: IdentifierNode; + superClass: ASTNode | null; + body: MethodDefinitionNode[]; +} + +/** + * Method definition node (class method) + */ +export interface MethodDefinitionNode extends ASTNode { + type: ASTNodeType.METHOD_DEFINITION; + key: ASTNode; + value: FunctionExpressionNode; + kind: "constructor" | "method" | "get" | "set"; + isStatic: boolean; +} + +/** + * Try statement node + */ +export interface TryStatementNode extends ASTNode { + type: ASTNodeType.TRY_STATEMENT; + block: BlockStatementNode; + handler: CatchClauseNode | null; + finalizer: BlockStatementNode | null; +} + +/** + * Catch clause node + */ +export interface CatchClauseNode extends ASTNode { + type: ASTNodeType.CATCH_CLAUSE; + param: IdentifierNode | null; + body: BlockStatementNode; +} + +/** + * Throw statement node + */ +export interface ThrowStatementNode extends ASTNode { + type: ASTNodeType.THROW_STATEMENT; + argument: ASTNode; +} + +/** + * Await expression node + */ +export interface AwaitExpressionNode extends ASTNode { + type: ASTNodeType.AWAIT_EXPRESSION; + argument: ASTNode; +} + +/** + * Switch statement node + */ +export interface SwitchStatementNode extends ASTNode { + type: ASTNodeType.SWITCH_STATEMENT; + discriminant: ASTNode; + cases: SwitchCaseNode[]; +} + +/** + * Switch case node + */ +export interface SwitchCaseNode extends ASTNode { + type: ASTNodeType.SWITCH_CASE; + test: ASTNode | null; // null for default + consequent: ASTNode[]; +} + +/** + * Do-while statement node + */ +export interface DoWhileStatementNode extends ASTNode { + type: ASTNodeType.DO_WHILE_STATEMENT; + body: ASTNode; + test: ASTNode; +} + /** * Ignition bytecode opcodes */ @@ -394,6 +518,16 @@ export enum Opcode { CREATE_ARRAY = 0x81, // Create array literal CREATE_CLOSURE = 0x82, // Create function closure + // Exception handling + TRY_START = 0x90, // Start try block (operand: catch handler offset) + TRY_END = 0x91, // End try block + THROW = 0x92, // Throw exception from accumulator + SET_CATCH_PARAM = 0x93, // Store caught exception to variable + + // Typeof + TYPEOF = 0xA0, // typeof accumulator → string in accumulator + INSTANCEOF = 0xA1, // accumulator instanceof register → boolean + // Special NOP = 0x00, // No operation DEBUGGER = 0xFF, // Debugger statement @@ -483,6 +617,11 @@ export class Lexer { return this.scanNumber(); } + // Template literals + if (char === '`') { + return this.scanTemplateLiteral(); + } + // Strings if (char === '"' || char === "'") { return this.scanString(); @@ -564,6 +703,10 @@ export class Lexer { const nextChar = this.source[this.position + 1]; // Three-character operators + if (char === "." && nextChar === "." && this.source[this.position + 2] === ".") { + this.advance(3); + return this.createJSToken(JSJSTokenType.SPREAD, "..."); + } if (char === "=" && nextChar === "=" && this.source[this.position + 2] === "=") { this.advance(3); return this.createJSToken(JSJSTokenType.STRICT_EQUAL, "==="); @@ -682,6 +825,29 @@ export class Lexer { "undefined": JSJSTokenType.UNDEFINED, "this": JSJSTokenType.THIS, "new": JSJSTokenType.NEW, + "class": JSJSTokenType.CLASS, + "extends": JSJSTokenType.EXTENDS, + "super": JSJSTokenType.SUPER, + "static": JSJSTokenType.STATIC, + "async": JSJSTokenType.ASYNC, + "await": JSJSTokenType.AWAIT, + "try": JSJSTokenType.TRY, + "catch": JSJSTokenType.CATCH, + "finally": JSJSTokenType.FINALLY, + "throw": JSJSTokenType.THROW, + "import": JSJSTokenType.IMPORT, + "export": JSJSTokenType.EXPORT, + "from": JSJSTokenType.FROM, + "typeof": JSJSTokenType.TYPEOF, + "instanceof": JSJSTokenType.INSTANCEOF, + "in": JSJSTokenType.IN, + "delete": JSJSTokenType.DELETE, + "void": JSJSTokenType.VOID, + "yield": JSJSTokenType.YIELD, + "switch": JSJSTokenType.SWITCH, + "case": JSJSTokenType.CASE, + "default": JSJSTokenType.DEFAULT, + "do": JSJSTokenType.DO, }; return keywords[value] || JSJSTokenType.IDENTIFIER; @@ -723,6 +889,28 @@ export class Lexer { char === "_" || char === "$"; } + /** + * Scan template literal (backtick strings) + * Simplified: treats as a plain string (no interpolation expressions) + */ + private scanTemplateLiteral(): JSToken { + this.advance(); // Skip opening backtick + const start = this.position; + while (this.position < this.source.length && this.source[this.position] !== '`') { + if (this.source[this.position] === "\\") { + this.advance(); // Skip escape char + } + if (this.source[this.position] === "\n") { + this.line++; + this.column = 0; + } + this.advance(); + } + const value = this.source.slice(start, this.position); + this.advance(); // Skip closing backtick + return this.createJSToken(JSJSTokenType.TEMPLATE_LITERAL, value); + } + /** * Check if character can be part of identifier */ @@ -788,6 +976,22 @@ export class Parser { this.advance(); if (this.match(JSJSTokenType.SEMICOLON)) this.advance(); return { type: ASTNodeType.CONTINUE_STATEMENT } as ASTNode; + case JSJSTokenType.CLASS: + return this.parseClassDeclaration(); + case JSJSTokenType.TRY: + return this.parseTryStatement(); + case JSJSTokenType.THROW: + return this.parseThrowStatement(); + case JSJSTokenType.SWITCH: + return this.parseSwitchStatement(); + case JSJSTokenType.DO: + return this.parseDoWhileStatement(); + case JSJSTokenType.ASYNC: + // async function declaration + if (this.tokens[this.position + 1]?.type === JSJSTokenType.FUNCTION) { + return this.parseAsyncFunctionDeclaration(); + } + return this.parseExpressionStatement(); case JSJSTokenType.LBRACE: return this.parseBlockStatement(); default: @@ -1103,6 +1307,55 @@ export class Parser { return this.parseNewExpression(); case JSJSTokenType.FUNCTION: return this.parseFunctionExpression(); + case JSJSTokenType.ASYNC: + // async function expression + if (this.tokens[this.position + 1]?.type === JSJSTokenType.FUNCTION) { + this.advance(); // skip async + const fe = this.parseFunctionExpression(); + (fe as unknown as { async: boolean }).async = true; + return fe; + } + return this.parseIdentifier(); + case JSJSTokenType.SUPER: + this.advance(); + return { type: ASTNodeType.IDENTIFIER, name: "super" } as IdentifierNode; + case JSJSTokenType.AWAIT: { + this.advance(); + const awaitArg = this.parsePostfixExpression(); + return { type: ASTNodeType.AWAIT_EXPRESSION, argument: awaitArg } as AwaitExpressionNode; + } + case JSJSTokenType.TYPEOF: { + this.advance(); + const typeofArg = this.parsePostfixExpression(); + return { type: ASTNodeType.UNARY_EXPRESSION, operator: "typeof", left: typeofArg, right: typeofArg } as unknown as ASTNode; + } + case JSJSTokenType.VOID: { + this.advance(); + this.parsePostfixExpression(); // evaluate and discard + return { type: ASTNodeType.LITERAL, value: null, raw: "undefined" } as LiteralNode; + } + case JSJSTokenType.DELETE: { + this.advance(); + const deleteTarget = this.parsePostfixExpression(); + return { type: ASTNodeType.UNARY_EXPRESSION, operator: "delete", left: deleteTarget, right: deleteTarget } as unknown as ASTNode; + } + case JSJSTokenType.TEMPLATE_LITERAL: { + const tmpl = this.advance(); + return { type: ASTNodeType.LITERAL, value: tmpl.value, raw: tmpl.value } as LiteralNode; + } + case JSJSTokenType.CLASS: + // Class expression + return this.parseClassExpression(); + case JSJSTokenType.MINUS: + case JSJSTokenType.PLUS: + case JSJSTokenType.LOGICAL_NOT: { + const op = this.advance(); + const operand = this.parsePostfixExpression(); + const opStr = op.type === JSJSTokenType.MINUS ? "-" + : op.type === JSJSTokenType.PLUS ? "+" + : "!"; + return { type: ASTNodeType.UNARY_EXPRESSION, operator: opStr, left: operand, right: operand } as unknown as ASTNode; + } case JSJSTokenType.LBRACE: return this.parseObjectExpression(); case JSJSTokenType.LBRACKET: @@ -1223,6 +1476,204 @@ export class Parser { return { type: ASTNodeType.ARRAY_EXPRESSION, elements }; } + /** + * Parse class declaration: class Name [extends Super] { ... } + */ + private parseClassDeclaration(): ClassDeclarationNode { + this.consume(JSJSTokenType.CLASS); + const id = this.parseIdentifier(); + + let superClass: ASTNode | null = null; + if (this.match(JSJSTokenType.EXTENDS)) { + this.advance(); + superClass = this.parsePostfixExpression(); + } + + const body = this.parseClassBody(); + + return { + type: ASTNodeType.CLASS_DECLARATION, + id, + superClass, + body, + }; + } + + /** + * Parse class body: { method() {}, static method() {}, ... } + */ + private parseClassBody(): MethodDefinitionNode[] { + this.consume(JSJSTokenType.LBRACE); + const methods: MethodDefinitionNode[] = []; + + while (!this.match(JSJSTokenType.RBRACE)) { + let isStatic = false; + let kind: "constructor" | "method" | "get" | "set" = "method"; + + // Check for static keyword + if (this.match(JSJSTokenType.STATIC)) { + isStatic = true; + this.advance(); + } + + // Check for get/set + if (this.match(JSJSTokenType.IDENTIFIER)) { + const val = this.peek().value; + if ((val === "get" || val === "set") && this.tokens[this.position + 1]?.type === JSJSTokenType.IDENTIFIER) { + kind = val as "get" | "set"; + this.advance(); + } + } + + // Method name + let key: ASTNode; + if (this.match(JSJSTokenType.LBRACKET)) { + // Computed property: [expr]() + this.advance(); + key = this.parseExpression(); + this.consume(JSJSTokenType.RBRACKET); + } else if (this.match(JSJSTokenType.IDENTIFIER) || this.match(JSJSTokenType.STRING) || this.match(JSJSTokenType.NUMBER)) { + key = this.match(JSJSTokenType.IDENTIFIER) ? this.parseIdentifier() : this.parseLiteral(); + } else { + // Could be constructor keyword as identifier + const token = this.advance(); + key = { type: ASTNodeType.IDENTIFIER, name: token.value } as IdentifierNode; + } + + // Check if this is the constructor + if (!isStatic && key.type === ASTNodeType.IDENTIFIER && (key as IdentifierNode).name === "constructor") { + kind = "constructor"; + } + + // Parse method parameters and body + this.consume(JSJSTokenType.LPAREN); + const params: IdentifierNode[] = []; + while (!this.match(JSJSTokenType.RPAREN)) { + params.push(this.parseIdentifier()); + if (!this.match(JSJSTokenType.RPAREN)) { + this.consume(JSJSTokenType.COMMA); + } + } + this.consume(JSJSTokenType.RPAREN); + const body = this.parseBlockStatement(); + + methods.push({ + type: ASTNodeType.METHOD_DEFINITION, + key, + value: { + type: ASTNodeType.FUNCTION_EXPRESSION, + id: null, + params, + body, + }, + kind, + isStatic, + }); + + // Optional semicolons between methods + if (this.match(JSJSTokenType.SEMICOLON)) this.advance(); + } + + this.consume(JSJSTokenType.RBRACE); + return methods; + } + + /** + * Parse try statement: try { ... } catch (e) { ... } finally { ... } + */ + private parseTryStatement(): TryStatementNode { + this.consume(JSJSTokenType.TRY); + const block = this.parseBlockStatement(); + + let handler: CatchClauseNode | null = null; + if (this.match(JSJSTokenType.CATCH)) { + this.advance(); + let param: IdentifierNode | null = null; + if (this.match(JSJSTokenType.LPAREN)) { + this.advance(); + param = this.parseIdentifier(); + this.consume(JSJSTokenType.RPAREN); + } + const body = this.parseBlockStatement(); + handler = { type: ASTNodeType.CATCH_CLAUSE, param, body }; + } + + let finalizer: BlockStatementNode | null = null; + if (this.match(JSJSTokenType.FINALLY)) { + this.advance(); + finalizer = this.parseBlockStatement(); + } + + return { type: ASTNodeType.TRY_STATEMENT, block, handler, finalizer }; + } + + /** + * Parse throw statement: throw expr; + */ + private parseThrowStatement(): ThrowStatementNode { + this.consume(JSJSTokenType.THROW); + const argument = this.parseExpression(); + if (this.match(JSJSTokenType.SEMICOLON)) this.advance(); + return { type: ASTNodeType.THROW_STATEMENT, argument }; + } + + /** + * Parse switch statement: switch (expr) { case val: ... default: ... } + */ + private parseSwitchStatement(): SwitchStatementNode { + this.consume(JSJSTokenType.SWITCH); + this.consume(JSJSTokenType.LPAREN); + const discriminant = this.parseExpression(); + this.consume(JSJSTokenType.RPAREN); + this.consume(JSJSTokenType.LBRACE); + + const cases: SwitchCaseNode[] = []; + while (!this.match(JSJSTokenType.RBRACE)) { + let test: ASTNode | null = null; + if (this.match(JSJSTokenType.CASE)) { + this.advance(); + test = this.parseExpression(); + } else if (this.match(JSJSTokenType.DEFAULT)) { + this.advance(); + } + this.consume(JSJSTokenType.COLON); + + const consequent: ASTNode[] = []; + while (!this.match(JSJSTokenType.CASE) && !this.match(JSJSTokenType.DEFAULT) && !this.match(JSJSTokenType.RBRACE)) { + consequent.push(this.parseStatement()); + } + cases.push({ type: ASTNodeType.SWITCH_CASE, test, consequent }); + } + + this.consume(JSJSTokenType.RBRACE); + return { type: ASTNodeType.SWITCH_STATEMENT, discriminant, cases }; + } + + /** + * Parse do-while statement: do { ... } while (expr); + */ + private parseDoWhileStatement(): DoWhileStatementNode { + this.consume(JSJSTokenType.DO); + const body = this.parseStatement(); + this.consume(JSJSTokenType.WHILE); + this.consume(JSJSTokenType.LPAREN); + const test = this.parseExpression(); + this.consume(JSJSTokenType.RPAREN); + if (this.match(JSJSTokenType.SEMICOLON)) this.advance(); + return { type: ASTNodeType.DO_WHILE_STATEMENT, body, test }; + } + + /** + * Parse async function declaration: async function name() { ... } + */ + private parseAsyncFunctionDeclaration(): FunctionDeclarationNode { + this.consume(JSJSTokenType.ASYNC); + const decl = this.parseFunctionDeclaration(); + // Mark as async by adding metadata property + (decl as unknown as { async: boolean }).async = true; + return decl; + } + /** * Parse literal */ @@ -1246,6 +1697,9 @@ export class Parser { case JSJSTokenType.NULL: value = null; break; + case JSJSTokenType.UNDEFINED: + // Use raw="undefined" to distinguish from null in code generation + return { type: ASTNodeType.LITERAL, value: null, raw: "undefined" }; default: value = null; } @@ -1268,6 +1722,29 @@ export class Parser { }; } + /** + * Parse class expression: class [Name] [extends Super] { ... } + */ + private parseClassExpression(): ASTNode { + this.consume(JSJSTokenType.CLASS); + let id: IdentifierNode | null = null; + if (this.match(JSJSTokenType.IDENTIFIER)) { + id = this.parseIdentifier(); + } + let superClass: ASTNode | null = null; + if (this.match(JSJSTokenType.EXTENDS)) { + this.advance(); + superClass = this.parsePostfixExpression(); + } + const body = this.parseClassBody(); + return { + type: ASTNodeType.CLASS_DECLARATION, + id: id || { type: ASTNodeType.IDENTIFIER, name: "" } as IdentifierNode, + superClass, + body, + } as ClassDeclarationNode; + } + /** * Check if current token is binary operator */ @@ -1289,6 +1766,8 @@ export class Parser { JSJSTokenType.GREATER_EQUAL, JSJSTokenType.LOGICAL_AND, JSJSTokenType.LOGICAL_OR, + JSJSTokenType.INSTANCEOF, + JSJSTokenType.IN, ].includes(token.type); } @@ -1457,6 +1936,53 @@ export class BytecodeGenerator { this.continueTargets[this.continueTargets.length - 1].push(continueRef); } break; + case ASTNodeType.CLASS_DECLARATION: + this.generateClassDeclaration(node as ClassDeclarationNode); + break; + case ASTNodeType.TRY_STATEMENT: + this.generateTryStatement(node as TryStatementNode); + break; + case ASTNodeType.THROW_STATEMENT: + this.generateThrowStatement(node as ThrowStatementNode); + break; + case ASTNodeType.SWITCH_STATEMENT: + this.generateSwitchStatement(node as SwitchStatementNode); + break; + case ASTNodeType.DO_WHILE_STATEMENT: + this.generateDoWhileStatement(node as DoWhileStatementNode); + break; + case ASTNodeType.AWAIT_EXPRESSION: + // Evaluate the argument (await is transparent in sync engine) + this.generateExpression((node as AwaitExpressionNode).argument); + break; + case ASTNodeType.UNARY_EXPRESSION: + this.generateUnaryExpression(node as { type: ASTNodeType; operator: string; left: ASTNode; right: ASTNode }); + break; + } + } + + /** + * Generate unary expression (typeof, delete, void, -, !, ~) + */ + private generateUnaryExpression(node: { operator: string; left: ASTNode }): void { + this.generateExpression(node.left); + switch (node.operator) { + case "typeof": + this.emit(Opcode.TYPEOF); + break; + case "-": + this.emit(Opcode.NEGATE); + break; + case "!": + this.emit(Opcode.LOGICAL_NOT); + break; + case "+": + // Unary + converts to number - no-op if already number + break; + case "delete": + // delete is a no-op in our engine for now, result is true + this.emit(Opcode.LDA_TRUE); + break; } } @@ -1573,6 +2099,16 @@ export class BytecodeGenerator { // Simplified: if left is truthy use left, else use right this.emit(Opcode.TO_BOOLEAN); break; + case "instanceof": + this.emit(Opcode.INSTANCEOF, reg); + break; + case "in": + // Simplified: check if property exists + this.emit(Opcode.TEST_EQUAL, reg); + break; + case "typeof": + this.emit(Opcode.TYPEOF); + break; } } @@ -1580,7 +2116,9 @@ export class BytecodeGenerator { * Generate literal */ private generateLiteral(node: LiteralNode): void { - if (node.value === null) { + if (node.value === null && node.raw === "undefined") { + this.emit(Opcode.LDA_UNDEFINED); + } else if (node.value === null) { this.emit(Opcode.LDA_NULL); } else if (node.value === undefined) { this.emit(Opcode.LDA_UNDEFINED); @@ -1609,6 +2147,8 @@ export class BytecodeGenerator { * Calling convention: function in accumulator, args in consecutive registers */ private generateCallExpression(node: CallExpressionNode): void { + let receiverReg = -1; + // If callee is a member expression, we need the object for 'this' if (node.callee.type === ASTNodeType.MEMBER_EXPRESSION) { const member = node.callee as MemberExpressionNode; @@ -1616,6 +2156,7 @@ export class BytecodeGenerator { this.generateExpression(member.object); const objReg = this.allocateRegister(); this.emit(Opcode.STAR, objReg); + receiverReg = objReg; // Get method from object if (member.computed) { @@ -1629,6 +2170,14 @@ export class BytecodeGenerator { const nameIdx = this.addConstant((member.property as IdentifierNode).name); this.emit(Opcode.GET_PROPERTY, nameIdx); } + + // Set 'this' to the receiver object for method calls + const savedAcc = this.allocateRegister(); + this.emit(Opcode.STAR, savedAcc); + this.emit(Opcode.LDAR, objReg); + const thisIdx = this.addConstant("this"); + this.emit(Opcode.STA_CONTEXT_SLOT, thisIdx); + this.emit(Opcode.LDAR, savedAcc); } else { this.generateExpression(node.callee); } @@ -1905,6 +2454,276 @@ export class BytecodeGenerator { } } + /** + * Generate class declaration bytecode + * class Foo extends Bar { constructor(x) { ... } method() { ... } static s() { ... } } + * → Creates constructor function, sets up prototype chain, adds methods + */ + private generateClassDeclaration(node: ClassDeclarationNode): void { + // Find constructor method + const ctorMethod = node.body.find(m => m.kind === "constructor"); + + if (ctorMethod) { + // Create constructor from the constructor method body + const ctorFunc: FunctionDeclarationNode = { + type: ASTNodeType.FUNCTION_DECLARATION, + id: node.id, + params: ctorMethod.value.params, + body: ctorMethod.value.body, + }; + const funcIndex = this.addConstant(ctorFunc); + this.emit(Opcode.CREATE_CLOSURE, funcIndex); + } else { + // Default constructor: empty function + const defaultCtor: FunctionDeclarationNode = { + type: ASTNodeType.FUNCTION_DECLARATION, + id: node.id, + params: [], + body: { type: ASTNodeType.BLOCK_STATEMENT, body: [] }, + }; + const funcIndex = this.addConstant(defaultCtor); + this.emit(Opcode.CREATE_CLOSURE, funcIndex); + } + + // Store constructor as class name + const classNameIdx = this.getVariableIndex(node.id.name); + this.emit(Opcode.STA_GLOBAL, classNameIdx); + + // Create default prototype object and set it on the constructor + // Every constructor needs a .prototype property + this.emit(Opcode.LDA_GLOBAL, classNameIdx); + const ctorRegInit = this.allocateRegister(); + this.emit(Opcode.STAR, ctorRegInit); + this.emit(Opcode.CREATE_OBJECT); + const protoInit = this.addConstant("prototype"); + this.emit(Opcode.SET_PROPERTY, protoInit, ctorRegInit); + + // Set up prototype if extends + if (node.superClass) { + // Load super class + this.generateExpression(node.superClass); + const superReg = this.allocateRegister(); + this.emit(Opcode.STAR, superReg); + + // Get super.prototype + const protoNameIdx = this.addConstant("prototype"); + this.emit(Opcode.GET_PROPERTY, protoNameIdx); + + // Create new object with super.prototype as __proto__ + const superProtoReg = this.allocateRegister(); + this.emit(Opcode.STAR, superProtoReg); + + // Load constructor, set its prototype + this.emit(Opcode.LDA_GLOBAL, classNameIdx); + const ctorReg = this.allocateRegister(); + this.emit(Opcode.STAR, ctorReg); + + // Set prototype.constructor = Foo + this.emit(Opcode.CREATE_OBJECT); + const newProtoReg = this.allocateRegister(); + this.emit(Opcode.STAR, newProtoReg); + + // Set Foo.prototype = newProto + this.emit(Opcode.LDAR, newProtoReg); + this.emit(Opcode.SET_PROPERTY, protoNameIdx, ctorReg); + } + + // Add instance methods to prototype + for (const method of node.body) { + if (method.kind === "constructor") continue; + if (method.isStatic) continue; + + // Load constructor + this.emit(Opcode.LDA_GLOBAL, classNameIdx); + const ctorReg2 = this.allocateRegister(); + this.emit(Opcode.STAR, ctorReg2); + + // Get prototype + const protoNameIdx2 = this.addConstant("prototype"); + this.emit(Opcode.GET_PROPERTY, protoNameIdx2); + const protoReg = this.allocateRegister(); + this.emit(Opcode.STAR, protoReg); + + // Create method closure + const methodFunc = this.addConstant(method.value); + this.emit(Opcode.CREATE_CLOSURE, methodFunc); + + // Set method on prototype + const methodName = method.key.type === ASTNodeType.IDENTIFIER + ? (method.key as IdentifierNode).name + : String((method.key as LiteralNode).value); + const methodNameIdx = this.addConstant(methodName); + this.emit(Opcode.SET_PROPERTY, methodNameIdx, protoReg); + } + + // Add static methods to constructor + for (const method of node.body) { + if (!method.isStatic) continue; + + // Load constructor + this.emit(Opcode.LDA_GLOBAL, classNameIdx); + const ctorReg3 = this.allocateRegister(); + this.emit(Opcode.STAR, ctorReg3); + + // Create method closure + const methodFunc = this.addConstant(method.value); + this.emit(Opcode.CREATE_CLOSURE, methodFunc); + + // Set method on constructor + const methodName = method.key.type === ASTNodeType.IDENTIFIER + ? (method.key as IdentifierNode).name + : String((method.key as LiteralNode).value); + const methodNameIdx = this.addConstant(methodName); + this.emit(Opcode.SET_PROPERTY, methodNameIdx, ctorReg3); + } + } + + /** + * Generate try/catch/finally bytecode + */ + private generateTryStatement(node: TryStatementNode): void { + // Emit TRY_START with placeholder for catch offset + const tryStartRef = this.instructions.length; + this.emit(Opcode.TRY_START, 0); // placeholder catch offset + + // Generate try block + for (const stmt of node.block.body) { + this.generateNode(stmt); + } + this.emit(Opcode.TRY_END); + + // Jump over catch block + const jumpOverCatchRef = this.instructions.length; + this.emit(Opcode.JUMP, 0); // placeholder + + // Patch TRY_START to point here (catch handler) + this.patchJump(tryStartRef); + + // Generate catch block + if (node.handler) { + if (node.handler.param) { + // Store caught exception to the parameter variable + const paramIdx = this.getVariableIndex(node.handler.param.name); + this.emit(Opcode.SET_CATCH_PARAM, paramIdx); + } + for (const stmt of node.handler.body.body) { + this.generateNode(stmt); + } + } + + // Patch jump-over-catch + this.patchJump(jumpOverCatchRef); + + // Generate finally block + if (node.finalizer) { + for (const stmt of node.finalizer.body) { + this.generateNode(stmt); + } + } + } + + /** + * Generate throw statement bytecode + */ + private generateThrowStatement(node: ThrowStatementNode): void { + this.generateExpression(node.argument); + this.emit(Opcode.THROW); + } + + /** + * Generate switch statement bytecode + */ + private generateSwitchStatement(node: SwitchStatementNode): void { + this.breakTargets.push([]); + + // Evaluate discriminant + this.generateExpression(node.discriminant); + const discReg = this.allocateRegister(); + this.emit(Opcode.STAR, discReg); + + const caseJumps: number[] = []; + let defaultJump = -1; + + // Generate test + jump for each case + for (let i = 0; i < node.cases.length; i++) { + const c = node.cases[i]; + if (c.test === null) { + // default case + defaultJump = i; + caseJumps.push(-1); + } else { + this.emit(Opcode.LDAR, discReg); + const testReg = this.allocateRegister(); + this.emit(Opcode.STAR, testReg); + this.generateExpression(c.test); + this.emit(Opcode.TEST_STRICT_EQUAL, testReg); + const jumpRef = this.instructions.length; + this.emit(Opcode.JUMP_IF_TRUE, 0); + caseJumps.push(jumpRef); + } + } + + // Jump to default or end + const jumpToDefaultOrEnd = this.instructions.length; + this.emit(Opcode.JUMP, 0); + + // Generate case bodies + const bodyStarts: number[] = []; + for (let i = 0; i < node.cases.length; i++) { + bodyStarts.push(this.calculateCurrentOffset()); + for (const stmt of node.cases[i].consequent) { + this.generateNode(stmt); + } + } + + const afterSwitch = this.calculateCurrentOffset(); + + // Patch case jumps + for (let i = 0; i < caseJumps.length; i++) { + if (caseJumps[i] >= 0) { + this.instructions[caseJumps[i]].operands[0] = bodyStarts[i]; + } + } + + // Patch default/end jump + if (defaultJump >= 0) { + this.instructions[jumpToDefaultOrEnd].operands[0] = bodyStarts[defaultJump]; + } else { + this.instructions[jumpToDefaultOrEnd].operands[0] = afterSwitch; + } + + // Patch break targets + const breakRefs = this.breakTargets.pop()!; + for (const ref of breakRefs) { + this.patchJump(ref); + } + } + + /** + * Generate do-while statement bytecode + */ + private generateDoWhileStatement(node: DoWhileStatementNode): void { + this.breakTargets.push([]); + this.continueTargets.push([]); + + const loopStart = this.calculateCurrentOffset(); + this.generateNode(node.body); + + const continueTarget = this.calculateCurrentOffset(); + const continueRefs = this.continueTargets.pop()!; + for (const ref of continueRefs) { + this.instructions[ref].operands[0] = continueTarget; + } + + this.generateExpression(node.test); + this.emit(Opcode.JUMP_IF_TRUE, loopStart); + + const breakRefs = this.breakTargets.pop()!; + for (const ref of breakRefs) { + this.patchJump(ref); + } + } + /** * Calculate current bytecode offset */ diff --git a/browser/src/engine/javascript/WindowObject.ts b/browser/src/engine/javascript/WindowObject.ts index eff3d56..5d3ff86 100644 --- a/browser/src/engine/javascript/WindowObject.ts +++ b/browser/src/engine/javascript/WindowObject.ts @@ -700,6 +700,9 @@ export class WindowObject { }, 1), ); + // Install Promise constructor + this.installPromise(); + // Install JSON object const jsonObj = createObject(); setProperty( @@ -2058,6 +2061,262 @@ export class WindowObject { return storageObj; } + /** + * Install Promise constructor and static methods + */ + private installPromise(): void { + // Internal promise state tracking + type PromiseState = "pending" | "fulfilled" | "rejected"; + + interface PromiseRecord { + state: PromiseState; + value: JSValue; + thenCallbacks: Array<{ onFulfilled: JSValue | null; onRejected: JSValue | null; childResolve: (v: JSValue) => void; childReject: (v: JSValue) => void }>; + } + + const resolvePromise = (record: PromiseRecord, value: JSValue) => { + if (record.state !== "pending") return; + record.state = "fulfilled"; + record.value = value; + for (const cb of record.thenCallbacks) { + if (cb.onFulfilled && cb.onFulfilled.type === "function") { + const fn = cb.onFulfilled.value as import("./JSValue.ts").JSFunction; + if (fn.isNative && fn.nativeImpl) { + try { + const result = fn.nativeImpl(value); + cb.childResolve(result); + } catch { + cb.childReject(createString("callback error")); + } + } else { + cb.childResolve(value); + } + } else { + cb.childResolve(value); + } + } + }; + + const rejectPromise = (record: PromiseRecord, reason: JSValue) => { + if (record.state !== "pending") return; + record.state = "rejected"; + record.value = reason; + for (const cb of record.thenCallbacks) { + if (cb.onRejected && cb.onRejected.type === "function") { + const fn = cb.onRejected.value as import("./JSValue.ts").JSFunction; + if (fn.isNative && fn.nativeImpl) { + try { + const result = fn.nativeImpl(reason); + cb.childResolve(result); + } catch { + cb.childReject(createString("callback error")); + } + } else { + cb.childReject(reason); + } + } else { + cb.childReject(reason); + } + } + }; + + const createPromiseObject = (record: PromiseRecord): JSValue => { + const promiseObj = createObject(); + + // .then(onFulfilled, onRejected) + setProperty(promiseObj, "then", createNativeFunction("then", (...args) => { + const onFulfilled = args[0] && args[0].type === "function" ? args[0] : null; + const onRejected = args[1] && args[1].type === "function" ? args[1] : null; + const childRecord: PromiseRecord = { state: "pending", value: createUndefined(), thenCallbacks: [] }; + const childPromise = createPromiseObject(childRecord); + + if (record.state === "fulfilled") { + if (onFulfilled && onFulfilled.type === "function") { + const fn = onFulfilled.value as import("./JSValue.ts").JSFunction; + if (fn.isNative && fn.nativeImpl) { + try { + const result = fn.nativeImpl(record.value); + resolvePromise(childRecord, result); + } catch { + rejectPromise(childRecord, createString("callback error")); + } + } else { + resolvePromise(childRecord, record.value); + } + } else { + resolvePromise(childRecord, record.value); + } + } else if (record.state === "rejected") { + if (onRejected && onRejected.type === "function") { + const fn = onRejected.value as import("./JSValue.ts").JSFunction; + if (fn.isNative && fn.nativeImpl) { + try { + const result = fn.nativeImpl(record.value); + resolvePromise(childRecord, result); + } catch { + rejectPromise(childRecord, createString("callback error")); + } + } else { + rejectPromise(childRecord, record.value); + } + } else { + rejectPromise(childRecord, record.value); + } + } else { + record.thenCallbacks.push({ + onFulfilled, + onRejected, + childResolve: (v) => resolvePromise(childRecord, v), + childReject: (v) => rejectPromise(childRecord, v), + }); + } + return childPromise; + }, 2)); + + // .catch(onRejected) + setProperty(promiseObj, "catch", createNativeFunction("catch", (...args) => { + const thenFn = getProperty(promiseObj, "then"); + if (thenFn.type === "function") { + const fn = thenFn.value as import("./JSValue.ts").JSFunction; + if (fn.isNative && fn.nativeImpl) { + return fn.nativeImpl(createNull(), args[0] || createUndefined()); + } + } + return promiseObj; + }, 1)); + + // .finally(onFinally) + setProperty(promiseObj, "finally", createNativeFunction("finally", (...args) => { + const onFinally = args[0]; + const thenFn = getProperty(promiseObj, "then"); + if (thenFn.type === "function") { + const fn = thenFn.value as import("./JSValue.ts").JSFunction; + if (fn.isNative && fn.nativeImpl) { + const wrapFulfilled = createNativeFunction("wrapFulfilled", (val) => { + if (onFinally && onFinally.type === "function") { + const cb = onFinally.value as import("./JSValue.ts").JSFunction; + if (cb.isNative && cb.nativeImpl) cb.nativeImpl(); + } + return val || createUndefined(); + }, 1); + const wrapRejected = createNativeFunction("wrapRejected", (reason) => { + if (onFinally && onFinally.type === "function") { + const cb = onFinally.value as import("./JSValue.ts").JSFunction; + if (cb.isNative && cb.nativeImpl) cb.nativeImpl(); + } + return reason || createUndefined(); + }, 1); + return fn.nativeImpl(wrapFulfilled, wrapRejected); + } + } + return promiseObj; + }, 1)); + + return promiseObj; + }; + + // Promise constructor: new Promise((resolve, reject) => { ... }) + const PromiseCtor = createNativeFunction("Promise", (...args) => { + const executor = args[0]; + const record: PromiseRecord = { state: "pending", value: createUndefined(), thenCallbacks: [] }; + const promiseObj = createPromiseObject(record); + + if (executor && executor.type === "function") { + const resolveFn = createNativeFunction("resolve", (...rArgs) => { + resolvePromise(record, rArgs[0] || createUndefined()); + return createUndefined(); + }, 1); + const rejectFn = createNativeFunction("reject", (...rArgs) => { + rejectPromise(record, rArgs[0] || createUndefined()); + return createUndefined(); + }, 1); + + const fn = executor.value as import("./JSValue.ts").JSFunction; + if (fn.isNative && fn.nativeImpl) { + try { + fn.nativeImpl(resolveFn, rejectFn); + } catch { + rejectPromise(record, createString("executor error")); + } + } + } + + return promiseObj; + }, 1); + + // Promise.resolve(value) + setProperty(PromiseCtor, "resolve", createNativeFunction("resolve", (...args) => { + const record: PromiseRecord = { state: "fulfilled", value: args[0] || createUndefined(), thenCallbacks: [] }; + return createPromiseObject(record); + }, 1)); + + // Promise.reject(reason) + setProperty(PromiseCtor, "reject", createNativeFunction("reject", (...args) => { + const record: PromiseRecord = { state: "rejected", value: args[0] || createUndefined(), thenCallbacks: [] }; + return createPromiseObject(record); + }, 1)); + + // Promise.all(iterable) + setProperty(PromiseCtor, "all", createNativeFunction("all", (...args) => { + const arr = args[0]; + const record: PromiseRecord = { state: "pending", value: createUndefined(), thenCallbacks: [] }; + const promiseObj = createPromiseObject(record); + + // For synchronous engine, resolve immediately if all values are resolved + if (arr && arr.type === "object") { + const values: JSValue[] = []; + const props = (arr.value as import("./JSValue.ts").JSObject).properties; + if (props) { + let allResolved = true; + const length = props.get("length"); + const len = length && "value" in length ? (length.value as number) : 0; + for (let i = 0; i < len; i++) { + const item = props.get(String(i)) || createUndefined(); + values.push(item); + // Check if it's a promise (has .then) + if (item.type === "object" && getProperty(item, "then").type === "function") { + allResolved = false; + } + } + if (allResolved) { + const resultArr = createObject(); + for (let i = 0; i < values.length; i++) { + setProperty(resultArr, String(i), values[i]); + } + setProperty(resultArr, "length", createNumber(values.length)); + resolvePromise(record, resultArr); + } + } + } + return promiseObj; + }, 1)); + + // Promise.race(iterable) + setProperty(PromiseCtor, "race", createNativeFunction("race", (...args) => { + const arr = args[0]; + const record: PromiseRecord = { state: "pending", value: createUndefined(), thenCallbacks: [] }; + const promiseObj = createPromiseObject(record); + + if (arr && arr.type === "object") { + const props = (arr.value as import("./JSValue.ts").JSObject).properties; + if (props) { + const length = props.get("length"); + const len = length && "value" in length ? (length.value as number) : 0; + for (let i = 0; i < len; i++) { + const item = props.get(String(i)); + if (item) { + resolvePromise(record, item); + break; + } + } + } + } + return promiseObj; + }, 1)); + + setProperty(this.context.global, "Promise", PromiseCtor); + } + /** * Convert a native JS value to a JSValue */ diff --git a/browser/src/engine/logging/LogSink.ts b/browser/src/engine/logging/LogSink.ts index 79c4b06..fa64804 100644 --- a/browser/src/engine/logging/LogSink.ts +++ b/browser/src/engine/logging/LogSink.ts @@ -35,13 +35,13 @@ export class StderrSink implements LogSink { ? "debug" : "log"; if (entry.data !== undefined) { - (console as Record void>)[method]( + (console as unknown as Record void>)[method]( prefix, entry.message, entry.data, ); } else { - (console as Record void>)[method](prefix, entry.message); + (console as unknown as Record void>)[method](prefix, entry.message); } } } diff --git a/browser/src/engine/network/connection/ConnectionManager.ts b/browser/src/engine/network/connection/ConnectionManager.ts index d4a6058..138ceee 100644 --- a/browser/src/engine/network/connection/ConnectionManager.ts +++ b/browser/src/engine/network/connection/ConnectionManager.ts @@ -61,8 +61,8 @@ export class ConnectionManager { * @param useTLS - Whether to use TLS * @returns Pooled connection */ - async acquire(host: string, port: Port, useTLS: boolean): Promise { - return this.pool.acquire(host, port, useTLS); + async acquire(host: string, port: Port, useTLS: boolean, hostname?: string): Promise { + return this.pool.acquire(host, port, useTLS, hostname); } /** diff --git a/browser/src/engine/network/connection/ConnectionPool.ts b/browser/src/engine/network/connection/ConnectionPool.ts index bead4b2..a3e6800 100644 --- a/browser/src/engine/network/connection/ConnectionPool.ts +++ b/browser/src/engine/network/connection/ConnectionPool.ts @@ -5,9 +5,9 @@ * Implements per-origin connection limits and idle connection management. */ -import type { ConnectionID, Port } from "../../../types/identifiers.ts"; -import type { Certificate, PooledConnection, Socket } from "../../../types/network.ts"; -import { ConnectionState } from "../../../types/network.ts"; +import type { ConnectionID, Port, ByteBuffer, FileDescriptor, ByteCount } from "../../../types/identifiers.ts"; +import type { Certificate, PooledConnection, Socket, SocketStats, SocketReadOptions, SocketWriteOptions } from "../../../types/network.ts"; +import { ConnectionState, SocketState } from "../../../types/network.ts"; import { AddressFamily, SocketImpl, SocketType } from "../primitives/Socket.ts"; import { type TCPConfig, TCPConnection } from "../primitives/TCPConnection.ts"; import { loadSystemCAs } from "../security/Certificate.ts"; @@ -15,6 +15,80 @@ import { TLSConnection } from "../security/TLSConnection.ts"; import { TLSSocket } from "../security/TLSSocket.ts"; import { ConnectionPoolStats, createConnectionPoolStats } from "./ConnectionPoolStats.ts"; +/** + * Socket wrapper around Deno.TlsConn for native TLS connections. + * Uses Deno's built-in TLS with OS certificate store for reliable cert validation. + */ +class DenoTlsSocket implements Socket { + private conn: Deno.TlsConn; + private _state: SocketState = SocketState.OPEN; + private _bytesRead: ByteCount = 0 as ByteCount; + private _bytesWritten: ByteCount = 0 as ByteCount; + private host: string; + private port: Port; + private sni: string; + + readonly fd: FileDescriptor = 0 as FileDescriptor; + readonly localAddress: string = "0.0.0.0"; + readonly localPort: Port = 0 as Port; + + get state(): SocketState { return this._state; } + get remoteAddress(): string { return this.host; } + get remotePort(): Port { return this.port; } + get serverName(): string { return this.sni; } + + constructor(conn: Deno.TlsConn, host: string, port: Port, sni: string) { + this.conn = conn; + this.host = host; + this.port = port; + this.sni = sni; + } + + async connect(_host: string, _port: Port): Promise { + // Already connected via Deno.connectTls + } + + async read(buffer: ByteBuffer, _options?: SocketReadOptions): Promise { + try { + const n = await this.conn.read(buffer); + if (n !== null) this._bytesRead = (this._bytesRead + n) as ByteCount; + return n; + } catch { + this._state = SocketState.CLOSED; + return null; + } + } + + async write(data: ByteBuffer, _options?: SocketWriteOptions): Promise { + try { + const n = await this.conn.write(data); + this._bytesWritten = (this._bytesWritten + n) as ByteCount; + return n; + } catch { + this._state = SocketState.CLOSED; + throw new Error("Socket write failed"); + } + } + + async close(): Promise { + this._state = SocketState.CLOSED; + try { this.conn.close(); } catch { /* already closed */ } + } + + getStats(): SocketStats { + const now = Date.now(); + return { + bytesRead: this._bytesRead, + bytesWritten: this._bytesWritten, + readOperations: 0, + writeOperations: 0, + errors: 0, + createdAt: now, + lastActiveAt: now, + } as unknown as SocketStats; + } +} + const DEFAULT_TCP_CONFIG: TCPConfig = { connectTimeout: 30000, // 30 seconds idleTimeout: 60000, // 60 seconds @@ -27,6 +101,15 @@ const DEFAULT_TCP_CONFIG: TCPConfig = { windowSize: 65535, // 64KB window }; +/** Default timeout for native TLS (Deno.connectTls) connections in ms */ +const NATIVE_TLS_CONNECT_TIMEOUT_MS = 10_000; + +/** Default timeout for custom TLS handshake fallback in ms */ +const CUSTOM_TLS_HANDSHAKE_TIMEOUT_MS = 5_000; + +/** Interval between automatic idle-connection cleanup sweeps in ms */ +const AUTO_CLEANUP_INTERVAL_MS = 30_000; + export class ConnectionPool { private connections: Map = new Map(); private maxConnectionsPerOrigin: number = 6; @@ -163,7 +246,6 @@ export class ConnectionPool { const pendingCount = this.pendingAcquisitions.get(key) || 0; if (activeCount + pendingCount >= this.maxConnectionsPerOrigin) { // Wait for an available connection, then loop back to retry - this.stats.missCount++; const waitStart = Date.now(); await this.waitForAvailableConnection(key); const waitTime = Date.now() - waitStart; @@ -174,8 +256,7 @@ export class ConnectionPool { // Track this pending acquisition to prevent over-allocation this.pendingAcquisitions.set(key, pendingCount + 1); - // Create new connection - // Increment miss count since we're not reusing an existing connection + // Create new connection — count as cache miss (no idle connection reused) this.stats.missCount++; try { @@ -225,29 +306,23 @@ export class ConnectionPool { connection.lastUsedAt = Date.now(); this.updateStats(); - // Notify the first waiter for any key that this connection belongs to - for (const [key, keyWaiters] of this.waiters.entries()) { - if (keyWaiters.length > 0) { - const pool = this.connections.get(key); - if (pool) { - const hasIdle = pool.some((c) => c.state === ConnectionState.IDLE); - const activeCount = pool.filter((c) => c.state === ConnectionState.IN_USE).length; - if (hasIdle || activeCount < this.maxConnectionsPerOrigin) { - const waiter = keyWaiters.shift()!; - clearTimeout(waiter.timer); - waiter.resolve(); - if (keyWaiters.length === 0) { - this.waiters.delete(key); - } - } - } else { - // Pool gone, wake waiter - const waiter = keyWaiters.shift()!; - clearTimeout(waiter.timer); - waiter.resolve(); - if (keyWaiters.length === 0) { - this.waiters.delete(key); - } + // Find the pool key for this connection and only notify its waiters + let connectionKey: string | undefined; + for (const [key, pool] of this.connections.entries()) { + if (pool.includes(connection)) { + connectionKey = key; + break; + } + } + + if (connectionKey) { + const keyWaiters = this.waiters.get(connectionKey); + if (keyWaiters && keyWaiters.length > 0) { + const waiter = keyWaiters.shift()!; + clearTimeout(waiter.timer); + waiter.resolve(); + if (keyWaiters.length === 0) { + this.waiters.delete(connectionKey); } } } @@ -367,53 +442,106 @@ export class ConnectionPool { useTLS: boolean, sniHostname?: string, ): Promise { - const socket = new SocketImpl(AddressFamily.IPv4, SocketType.STREAM); - const tcpConnection = new TCPConnection(socket, DEFAULT_TCP_CONFIG); - - // Establish TCP connection first (using IP address) - await tcpConnection.connect(host, port); - if (useTLS) { - // Use provided SNI hostname, or fall back to host if not provided + // Use Deno's native TLS for reliable certificate validation const tlsServerName = sniHostname || host; - - // Load system CA certificates for validation - const trustedCAs = await this.getSystemCAs(); - - // Create TLS connection over established TCP - // Certificate validation enabled for production security - const tlsConnection = new TLSConnection(socket, { - minVersion: 0x0303, // TLS 1.2 minimum for broad compatibility - maxVersion: 0x0304, // TLS 1.3 - cipherSuites: [ - // TLS 1.3 cipher suites - 0x1301, - 0x1302, - 0x1303, - // TLS 1.2 cipher suites (for servers that don't support TLS 1.3) - 0xc02b, - 0xc02f, - 0xc02c, - 0xc030, - 0xcca9, - 0xcca8, - ], - verifyPeerCertificate: true, // Enable certificate validation for security - trustedCAs, // System root CA certificates - allowSelfSigned: false, // Reject self-signed certificates in production - serverName: tlsServerName, // Use hostname for SNI, not IP address - alpnProtocols: ["http/1.1"], - enableSessionResumption: false, - sessionTicketLifetime: 7200000, - }); - - // Perform TLS handshake - await tlsConnection.connect(tlsServerName); - - // Return TLSSocket wrapper for encrypted I/O - return new TLSSocket(tlsConnection); + try { + // Wrap Deno.connectTls in a 10s timeout to prevent hanging on unresponsive servers + let nativeTlsTimer: number | undefined; + let conn: Deno.TlsConn; + const tlsConnectPromise = Deno.connectTls({ + hostname: tlsServerName, + port, + alpnProtocols: ["http/1.1"], + }); + try { + conn = await Promise.race([ + tlsConnectPromise, + new Promise((_, reject) => { + nativeTlsTimer = setTimeout(() => reject(new Error(`TLS connection to ${tlsServerName}:${port} timed out after ${NATIVE_TLS_CONNECT_TIMEOUT_MS}ms`)), NATIVE_TLS_CONNECT_TIMEOUT_MS) as unknown as number; + }), + ]); + } catch (err) { + // If timeout won the race, the TLS connect may still resolve later — close it to prevent leak + tlsConnectPromise.then((c) => { try { c.close(); } catch { /* already closed */ } }).catch(() => {}); + throw err; + } finally { + if (nativeTlsTimer !== undefined) clearTimeout(nativeTlsTimer); + } + return new DenoTlsSocket(conn, host, port as Port, tlsServerName); + } catch (nativeErr) { + // Only fall back to custom TLS if Deno.connectTls is truly unavailable, + // NOT on certificate validation errors (which should propagate) + const errMsg = (nativeErr as Error).message || ""; + if ( + errMsg.includes("certificate") || + errMsg.includes("CERTIFICATE") || + errMsg.includes("self-signed") || + errMsg.includes("expired") || + errMsg.includes("hostname mismatch") || + errMsg.includes("unknown CA") || + errMsg.includes("InvalidData") || + errMsg.includes("timed out") + ) { + throw nativeErr; // Don't mask cert/timeout errors with custom TLS fallback + } + // Fallback to custom TLS stack if Deno.connectTls unavailable + // Wrap entire custom handshake in 5s timeout for fail-fast behavior + // Track the underlying socket so we can close it on timeout even if + // the TLS handshake hasn't completed (before TLSSocket wrapping) + let rawSocket: SocketImpl | undefined; + const customTlsPromise = (async () => { + const af = host.includes(":") ? AddressFamily.IPv6 : AddressFamily.IPv4; + const socket = new SocketImpl(af, SocketType.STREAM); + rawSocket = socket; + const tcpConnection = new TCPConnection(socket, DEFAULT_TCP_CONFIG); + await tcpConnection.connect(host, port); + + const trustedCAs = await this.getSystemCAs(); + const tlsConnection = new TLSConnection(socket, { + minVersion: 0x0303, + maxVersion: 0x0304, + cipherSuites: [0x1301, 0x1302, 0x1303, 0xc02b, 0xc02f, 0xc02c, 0xc030, 0xcca9, 0xcca8], + verifyPeerCertificate: true, + trustedCAs, + allowSelfSigned: false, + serverName: tlsServerName, + alpnProtocols: ["http/1.1"], + enableSessionResumption: false, + sessionTicketLifetime: 7200000, + }); + await tlsConnection.connect(tlsServerName); + return new TLSSocket(tlsConnection); + })(); + + let customTlsTimer: number | undefined; + try { + return await Promise.race([ + customTlsPromise, + new Promise((_, reject) => { + customTlsTimer = setTimeout(() => reject(new Error(`Custom TLS handshake to ${tlsServerName}:${port} timed out after ${CUSTOM_TLS_HANDSHAKE_TIMEOUT_MS}ms`)), CUSTOM_TLS_HANDSHAKE_TIMEOUT_MS) as unknown as number; + }), + ]); + } catch (err) { + // Close the underlying raw socket to prevent leak even if TLS wrapping never completed + if (rawSocket) { + try { await rawSocket.close(); } catch { /* already closed */ } + } + // If timeout won, the custom TLS handshake may still complete — close to prevent leak + customTlsPromise.then((sock) => { + try { sock.close(); } catch { /* already closed */ } + }).catch(() => {}); + throw err; + } finally { + if (customTlsTimer !== undefined) clearTimeout(customTlsTimer); + } + } } + const af = host.includes(":") ? AddressFamily.IPv6 : AddressFamily.IPv4; + const socket = new SocketImpl(af, SocketType.STREAM); + const tcpConnection = new TCPConnection(socket, DEFAULT_TCP_CONFIG); + await tcpConnection.connect(host, port); return socket; } @@ -479,6 +607,6 @@ export class ConnectionPool { this.closeIdleConnections().catch((error) => { console.error("Error during automatic connection cleanup:", error); }); - }, 30000); // Clean up every 30 seconds + }, AUTO_CLEANUP_INTERVAL_MS); } } diff --git a/browser/src/engine/network/protocols/HTTPHeaders.ts b/browser/src/engine/network/protocols/HTTPHeaders.ts index e016a82..c2b2613 100644 --- a/browser/src/engine/network/protocols/HTTPHeaders.ts +++ b/browser/src/engine/network/protocols/HTTPHeaders.ts @@ -38,10 +38,16 @@ export class HTTPHeaderParser { const name = line.substring(0, colonIndex).trim().toLowerCase(); const value = line.substring(colonIndex + 1).trim(); - // Handle multiple headers with same name (e.g., Set-Cookie) + // Handle multiple headers with same name if (headers.has(name)) { - // Append to existing value with comma separator - headers.set(name, `${headers.get(name)}, ${value}`); + if (name === "set-cookie") { + // Set-Cookie headers MUST NOT be comma-joined (RFC 6265) + // Store as newline-separated so they can be split later + headers.set(name, `${headers.get(name)}\n${value}`); + } else { + // Other headers: comma-join per RFC 7230 Section 3.2.2 + headers.set(name, `${headers.get(name)}, ${value}`); + } } else { headers.set(name, value); } diff --git a/browser/src/engine/network/resolution/DNSCache.ts b/browser/src/engine/network/resolution/DNSCache.ts index 3d9a98d..ef07852 100644 --- a/browser/src/engine/network/resolution/DNSCache.ts +++ b/browser/src/engine/network/resolution/DNSCache.ts @@ -24,6 +24,7 @@ export class DNSCache { private misses: number = 0; private cleanupInterval: number | null = null; private cleanupIntervalMs: Duration = 60000; // Cleanup every 60 seconds + private maxSize: number = 1000; // Max cache entries to prevent unbounded growth constructor() { // Start automatic cleanup timer @@ -61,6 +62,14 @@ export class DNSCache { * @param result - DNS result to cache */ set(result: DNSResult): void { + // Evict oldest entries if at capacity + if (this.cache.size >= this.maxSize && !this.cache.has(result.hostname)) { + // Remove the first (oldest) entry + const firstKey = this.cache.keys().next().value; + if (firstKey !== undefined) { + this.cache.delete(firstKey); + } + } this.cache.set(result.hostname, result); } diff --git a/browser/src/engine/network/resolution/DNSResolver.ts b/browser/src/engine/network/resolution/DNSResolver.ts index 69b0f46..071561c 100644 --- a/browser/src/engine/network/resolution/DNSResolver.ts +++ b/browser/src/engine/network/resolution/DNSResolver.ts @@ -114,13 +114,20 @@ export class DNSResolver { * @param type - DNS record type (A or AAAA) * @returns DNS resolution result */ - async resolve(hostname: string, type: DNSRecordType = DNSRecordType.A): Promise { + async resolve(hostname: string, type: DNSRecordType = DNSRecordType.A, _cnameDepth: number = 0): Promise { // Try DNS-over-HTTPS first if configured if (this.dohEndpoint) { try { return await this.queryDoH(hostname, type); } catch (error) { - this.dnsLogger.warn(`DoH query failed: ${(error as Error).message}, falling back to UDP`); + const msg = (error as Error).message; + // Handle CNAME-only responses from DoH + if (msg.startsWith("CNAME_ONLY:") && _cnameDepth < 10) { + const cnameTarget = msg.slice("CNAME_ONLY:".length); + this.dnsLogger.info(`Following CNAME: ${hostname} -> ${cnameTarget}`); + return await this.resolve(cnameTarget, type, _cnameDepth + 1); + } + this.dnsLogger.warn(`DoH query failed: ${msg}, falling back to UDP`); } } @@ -133,7 +140,14 @@ export class DNSResolver { return await this.queryUDP(hostname, type, nameserver); } catch (error) { lastError = error as Error; - this.dnsLogger.warn(`DNS query to ${nameserver} failed: ${lastError.message}`); + const msg = lastError.message; + // Handle CNAME-only responses — follow the CNAME chain + if (msg.startsWith("CNAME_ONLY:") && _cnameDepth < 10) { + const cnameTarget = msg.slice("CNAME_ONLY:".length); + this.dnsLogger.info(`Following CNAME: ${hostname} -> ${cnameTarget}`); + return await this.resolve(cnameTarget, type, _cnameDepth + 1); + } + this.dnsLogger.warn(`DNS query to ${nameserver} failed: ${msg}`); } } @@ -329,6 +343,12 @@ export class DNSResolver { } if (addresses.length === 0) { + // If we got a CNAME but no A/AAAA records, throw with CNAME target + // so the caller can follow the CNAME chain + if (hostname && hostname !== "" && header.ancount > 0) { + const err = new Error(`CNAME_ONLY:${hostname}`); + throw err; + } throw new Error("No addresses found in DNS response"); } diff --git a/browser/src/engine/network/security/Certificate.ts b/browser/src/engine/network/security/Certificate.ts index 2a58228..d0fe823 100644 --- a/browser/src/engine/network/security/Certificate.ts +++ b/browser/src/engine/network/security/Certificate.ts @@ -174,6 +174,12 @@ function buildCertificateChain( * Verify certificate signature */ async function verifySignature(cert: Certificate, issuer: Certificate): Promise { + // Reject weak signature algorithms + if (cert.signatureAlgorithm === "RSA-SHA1" || cert.signatureAlgorithm === "RSA-MD5") { + console.error(`Rejecting certificate with weak signature algorithm: ${cert.signatureAlgorithm}`); + return false; + } + // The TBS (To-Be-Signed) certificate data is what was signed const tbsData = cert.tbsCertificate; if (!tbsData) { @@ -288,7 +294,7 @@ export async function checkRevocationStatus(cert: Certificate, issuerCert?: Cert const issuerDNBytes = (cert as Certificate & { issuerRaw?: ByteBuffer }).issuerRaw ?? (issuerCert as Certificate & { issuerRaw?: ByteBuffer }).issuerRaw ?? derEncodeDistinguishedName(cert.issuer); - nameHashBytes = new Uint8Array(await crypto.subtle.digest("SHA-1", issuerDNBytes)); + nameHashBytes = new Uint8Array(await crypto.subtle.digest("SHA-1", issuerDNBytes as BufferSource)); keyHashBytes = new Uint8Array(await crypto.subtle.digest("SHA-1", issuerCert.publicKey)); } else { // No issuer cert available — use zero-byte placeholders (OCSP responder may return "unknown") @@ -1110,30 +1116,68 @@ function parseOID(data: ByteBuffer, offset: number): string { const oidBytes = data.slice(offset + 2, offset + 2 + len); // Common signature algorithm OIDs + // 1.2.840.113549.1.1.1 — RSA encryption + if (matchesOID(oidBytes, [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01])) { + return "RSA"; + } + // 1.2.840.113549.1.1.4 — MD5WithRSA + if (matchesOID(oidBytes, [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x04])) { + return "RSA-MD5"; + } + // 1.2.840.113549.1.1.5 — SHA1WithRSA + if (matchesOID(oidBytes, [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x05])) { + return "RSA-SHA1"; + } + // 1.2.840.113549.1.1.11 — SHA256WithRSA if (matchesOID(oidBytes, [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0b])) { return "RSA-SHA256"; } + // 1.2.840.113549.1.1.12 — SHA384WithRSA if (matchesOID(oidBytes, [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0c])) { return "RSA-SHA384"; } + // 1.2.840.113549.1.1.13 — SHA512WithRSA if (matchesOID(oidBytes, [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0d])) { return "RSA-SHA512"; } + // 1.2.840.113549.1.1.14 — SHA224WithRSA + if (matchesOID(oidBytes, [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0e])) { + return "RSA-SHA224"; + } + // 1.2.840.10045.4.3.2 — ECDSA-SHA256 if (matchesOID(oidBytes, [0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x02])) { return "ECDSA-SHA256"; } + // 1.2.840.10045.4.3.3 — ECDSA-SHA384 if (matchesOID(oidBytes, [0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x03])) { return "ECDSA-SHA384"; } - } - // Decode OID bytes to dotted notation for error message - if (data[offset] === 0x06) { - const len = data[offset + 1]; - const oidBytes = data.slice(offset + 2, offset + 2 + len); + // 1.2.840.10045.4.3.4 — ECDSA-SHA512 + if (matchesOID(oidBytes, [0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x04])) { + return "ECDSA-SHA512"; + } + // 1.2.840.10045.4.3.1 — ECDSA-SHA224 + if (matchesOID(oidBytes, [0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x01])) { + return "ECDSA-SHA224"; + } + // 1.2.840.10045.2.1 — EC public key + if (matchesOID(oidBytes, [0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01])) { + return "EC"; + } + // 1.3.101.112 — Ed25519 + if (matchesOID(oidBytes, [0x2b, 0x65, 0x70])) { + return "Ed25519"; + } + // 1.3.101.113 — Ed448 + if (matchesOID(oidBytes, [0x2b, 0x65, 0x71])) { + return "Ed448"; + } + // Unknown OID — return dotted notation string instead of throwing const oidStr = decodeOIDBytes(oidBytes); - throw new Error("Unknown signature algorithm OID: " + oidStr); + return "OID:" + oidStr; } - throw new Error("Unknown signature algorithm OID: no OID tag found"); + // No OID tag — return generic string instead of throwing + return "UNKNOWN"; } /** diff --git a/browser/src/engine/network/security/TLSConnection.ts b/browser/src/engine/network/security/TLSConnection.ts index 6a5ee74..a9b1abd 100644 --- a/browser/src/engine/network/security/TLSConnection.ts +++ b/browser/src/engine/network/security/TLSConnection.ts @@ -515,10 +515,11 @@ export class TLSConnection { const encryptedMessages = await this.receiveEncryptedHandshakeMessages(); const certificate = encryptedMessages.find((m) => m.type === "Certificate"); + const certificateVerify = encryptedMessages.find((m) => m.type === "CertificateVerify"); const serverFinished = encryptedMessages.find((m) => m.type === "Finished"); - if (!certificate || !serverFinished) { - throw new TLSError("Invalid server handshake: missing Certificate or Finished"); + if (!certificate || !certificateVerify || !serverFinished) { + throw new TLSError("Invalid server handshake: missing Certificate, CertificateVerify, or Finished"); } // 5. Validate server certificate @@ -551,6 +552,45 @@ export class TLSConnection { } } + // 5b. Verify CertificateVerify signature over the transcript + // RFC 8446 Section 4.4.3: The signature is over a concatenation of: + // - 64 spaces (0x20) + // - "TLS 1.3, server CertificateVerify" context string + // - 0x00 separator + // - Hash of transcript up to (but not including) the CertificateVerify message + // The transcript at this point includes: ClientHello, ServerHello, EncryptedExtensions, Certificate + // (handshakeMessages already has CertificateVerify and Finished appended by receiveEncryptedHandshakeMessages, + // so we need to compute the hash over messages BEFORE CertificateVerify) + const cvSignatureAlgorithm = certificateVerify.signatureAlgorithm as number; + const cvSignature = certificateVerify.signature as ByteBuffer; + + // Find the transcript messages before CertificateVerify: + // receiveEncryptedHandshakeMessages appended messages in order, so we need + // all messages except the last two (CertificateVerify and Finished) + const transcriptBeforeCV = this.handshakeMessages.slice(0, this.handshakeMessages.length - 2); + const transcriptHash = new Uint8Array( + await crypto.subtle.digest(hashAlgorithm, concat(...transcriptBeforeCV)), + ); + + // Build the content that was signed + const contextString = new TextEncoder().encode("TLS 1.3, server CertificateVerify"); + const signedContent = new Uint8Array(64 + contextString.length + 1 + transcriptHash.length); + signedContent.fill(0x20, 0, 64); // 64 spaces + signedContent.set(contextString, 64); + signedContent[64 + contextString.length] = 0x00; + signedContent.set(transcriptHash, 64 + contextString.length + 1); + + // Determine Web Crypto algorithm from TLS signature algorithm + const cvValid = await this.verifyCertificateVerifySignature( + cvSignatureAlgorithm, + cvSignature, + signedContent, + this.peerCertificate, + ); + if (!cvValid) { + throw new TLSError("CertificateVerify signature verification failed"); + } + this.state = TLSHandshakeState.CERTIFICATE; // 6. Compute application transcript hash BEFORE sending client Finished @@ -638,15 +678,31 @@ export class TLSConnection { let serverKeyExchangeMsg: TLSHandshakeMessage | null = null; let gotServerHelloDone = false; + // TLS 1.2 expects at most: Certificate, ServerKeyExchange, CertificateRequest, + // ServerHelloDone, plus a few ChangeCipherSpec records. Cap to prevent abuse. + const maxHandshakeRecords = 10; + let handshakeRecordCount = 0; + while (!gotServerHelloDone) { + if (++handshakeRecordCount > maxHandshakeRecords) { + throw new TLSError( + `TLS 1.2 handshake exceeded ${maxHandshakeRecords} records without ServerHelloDone`, + ); + } + let record = await this.readRecord(); if (record === null) { throw new TLSError("Connection closed unexpectedly during TLS 1.2 handshake"); } - // Skip ChangeCipherSpec + // Skip ChangeCipherSpec (counted against the record limit above) while (record.type === TLSRecordType.CHANGE_CIPHER_SPEC) { + if (++handshakeRecordCount > maxHandshakeRecords) { + throw new TLSError( + `TLS 1.2 handshake exceeded ${maxHandshakeRecords} records without ServerHelloDone`, + ); + } record = await this.readRecord(); if (record === null) { throw new TLSError("Connection closed unexpectedly during TLS 1.2 handshake"); @@ -1088,86 +1144,90 @@ export class TLSConnection { throw new Error("TLS connection not established"); } - // Read TLS record from socket - const record = await this.readRecord(); + // Loop to skip post-handshake messages (NewSessionTicket, KeyUpdate, etc.) + // without recursion — a server may send many consecutive post-handshake records. + while (true) { + // Read TLS record from socket + const record = await this.readRecord(); - if (record === null) { - return null; // Connection closed gracefully - } + if (record === null) { + return null; // Connection closed gracefully + } - if (record.type !== TLSRecordType.APPLICATION_DATA) { - // Handle close_notify alert gracefully - if (record.type === TLSRecordType.ALERT) { - const alertDesc = record.data[1]; - if (alertDesc === TLSAlertDescription.CLOSE_NOTIFY) { - return null; // Connection closed gracefully + if (record.type !== TLSRecordType.APPLICATION_DATA) { + // Handle close_notify alert gracefully + if (record.type === TLSRecordType.ALERT) { + const alertDesc = record.data[1]; + if (alertDesc === TLSAlertDescription.CLOSE_NOTIFY) { + return null; // Connection closed gracefully + } } + throw new TLSError(`Unexpected record type: ${record.type}`); } - throw new TLSError(`Unexpected record type: ${record.type}`); - } - let appData: Uint8Array; + let appData: Uint8Array; - if (this.negotiatedVersion === TLSVersion.TLS_1_2) { - // TLS 1.2: Decrypt with explicit nonce, no inner content type - appData = await this.decryptTLS12Record(record.data, TLSRecordType.APPLICATION_DATA); - } else { - // TLS 1.3: Decrypt with XOR'd nonce and strip inner content type + if (this.negotiatedVersion === TLSVersion.TLS_1_2) { + // TLS 1.2: Decrypt with explicit nonce, no inner content type + appData = await this.decryptTLS12Record(record.data, TLSRecordType.APPLICATION_DATA); + } else { + // TLS 1.3: Decrypt with XOR'd nonce and strip inner content type - // Construct AAD (record header) - const aad = new Uint8Array(5); - aad[0] = TLSRecordType.APPLICATION_DATA; // 0x17 - aad[1] = 0x03; // TLS 1.2 version for compatibility - aad[2] = 0x03; - aad[3] = (record.length >> 8) & 0xFF; - aad[4] = record.length & 0xFF; + // Construct AAD (record header) + const aad = new Uint8Array(5); + aad[0] = TLSRecordType.APPLICATION_DATA; // 0x17 + aad[1] = 0x03; // TLS 1.2 version for compatibility + aad[2] = 0x03; + aad[3] = (record.length >> 8) & 0xFF; + aad[4] = record.length & 0xFF; - // Decrypt record using application traffic keys and sequence counter - let plaintext: ByteBuffer; - try { - plaintext = await decrypt( - record.data, - this.sessionKeys!.serverWriteKey, - this.sessionKeys!.serverWriteIV, - this.serverRecordSeq, - aad as ByteBuffer, - this.negotiatedCipherSuite, - ); - // Only increment sequence counter AFTER successful decryption - this.serverRecordSeq++; - } catch (error) { - throw new TLSError( - `TLS 1.3 record decryption failed: ${ - (error as Error).message || "authentication tag verification failed" - }`, - ); - } + // Decrypt record using application traffic keys and sequence counter + let plaintext: ByteBuffer; + try { + plaintext = await decrypt( + record.data, + this.sessionKeys!.serverWriteKey, + this.sessionKeys!.serverWriteIV, + this.serverRecordSeq, + aad as ByteBuffer, + this.negotiatedCipherSuite, + ); + // Only increment sequence counter AFTER successful decryption + this.serverRecordSeq++; + } catch (error) { + throw new TLSError( + `TLS 1.3 record decryption failed: ${ + (error as Error).message || "authentication tag verification failed" + }`, + ); + } - // TLS 1.3: Inner plaintext has content type at the end - // Format: [data][content_type][padding zeros] - let actualLength = plaintext.byteLength; - while (actualLength > 0 && plaintext[actualLength - 1] === 0) { - actualLength--; - } - // Now the byte at actualLength-1 is the content type - skip it - if (actualLength > 0) { - actualLength--; - } - appData = plaintext.slice(0, actualLength); + // TLS 1.3: Inner plaintext has content type at the end + // Format: [data][content_type][padding zeros] + let actualLength = plaintext.byteLength; + while (actualLength > 0 && plaintext[actualLength - 1] === 0) { + actualLength--; + } + // Now the byte at actualLength-1 is the content type - skip it + if (actualLength > 0) { + actualLength--; + } + appData = plaintext.slice(0, actualLength); - // Check inner content type - skip post-handshake messages (e.g., NewSessionTicket) - const innerContentType = plaintext[actualLength]; - if (innerContentType === TLSRecordType.HANDSHAKE) { - // Post-handshake message (NewSessionTicket, KeyUpdate, etc.) - skip and read next record - return this.read(buffer); + // Check inner content type - skip post-handshake messages (e.g., NewSessionTicket) + const innerContentType = plaintext[actualLength]; + if (innerContentType === TLSRecordType.HANDSHAKE) { + // Post-handshake message — continue loop to read next record + continue; + } } - } - // Copy to buffer - const length = Math.min(buffer.byteLength, appData.byteLength); - buffer.set(appData.slice(0, length)); + // Copy to buffer + const length = Math.min(buffer.byteLength, appData.byteLength); + buffer.set(appData.slice(0, length)); - return length; + return length; + } } /** @@ -1330,6 +1390,80 @@ export class TLSConnection { }; } + /** + * Verify CertificateVerify signature using Web Crypto + */ + private async verifyCertificateVerifySignature( + signatureAlgorithm: number, + signature: ByteBuffer, + signedContent: Uint8Array, + cert: Certificate, + ): Promise { + try { + // Map TLS signature algorithm to Web Crypto parameters + let algorithm: AlgorithmIdentifier | RsaPssParams | EcdsaParams; + let importAlgorithm: AlgorithmIdentifier | RsaHashedImportParams | EcKeyImportParams; + const keyUsages: KeyUsage[] = ["verify"]; + + switch (signatureAlgorithm) { + case 0x0401: // rsa_pkcs1_sha256 + algorithm = { name: "RSASSA-PKCS1-v1_5" }; + importAlgorithm = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }; + break; + case 0x0501: // rsa_pkcs1_sha384 + algorithm = { name: "RSASSA-PKCS1-v1_5" }; + importAlgorithm = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-384" }; + break; + case 0x0601: // rsa_pkcs1_sha512 + algorithm = { name: "RSASSA-PKCS1-v1_5" }; + importAlgorithm = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }; + break; + case 0x0804: // rsa_pss_rsae_sha256 + algorithm = { name: "RSA-PSS", saltLength: 32 } as RsaPssParams; + importAlgorithm = { name: "RSA-PSS", hash: "SHA-256" }; + break; + case 0x0805: // rsa_pss_rsae_sha384 + algorithm = { name: "RSA-PSS", saltLength: 48 } as RsaPssParams; + importAlgorithm = { name: "RSA-PSS", hash: "SHA-384" }; + break; + case 0x0806: // rsa_pss_rsae_sha512 + algorithm = { name: "RSA-PSS", saltLength: 64 } as RsaPssParams; + importAlgorithm = { name: "RSA-PSS", hash: "SHA-512" }; + break; + case 0x0403: // ecdsa_secp256r1_sha256 + algorithm = { name: "ECDSA", hash: "SHA-256" } as EcdsaParams; + importAlgorithm = { name: "ECDSA", namedCurve: "P-256" }; + break; + case 0x0503: // ecdsa_secp384r1_sha384 + algorithm = { name: "ECDSA", hash: "SHA-384" } as EcdsaParams; + importAlgorithm = { name: "ECDSA", namedCurve: "P-384" }; + break; + default: + // Unknown algorithm — cannot verify, treat as failure + return false; + } + + // Import the server's public key from the certificate + const publicKey = await crypto.subtle.importKey( + "spki", + cert.publicKey, + importAlgorithm, + false, + keyUsages, + ); + + return await crypto.subtle.verify( + algorithm, + publicKey, + signature, + signedContent as ByteBuffer, + ); + } catch { + // If Web Crypto doesn't support the algorithm or key format, fail closed + return false; + } + } + /** * Create Finished message */ @@ -1468,9 +1602,11 @@ export class TLSConnection { private async receiveEncryptedHandshakeMessages(): Promise { const messages: TLSHandshakeMessage[] = []; - // Read until we have all expected messages - // Server sends: EncryptedExtensions, Certificate, CertificateVerify, Finished - while (messages.length < 4) { + // Read until we receive the Finished message. + // Typical sequence: EncryptedExtensions, Certificate, CertificateVerify, Finished + // but some servers vary (e.g., PSK resumption sends only EncryptedExtensions + Finished). + // Cap at 10 messages to prevent infinite loops on misbehaving servers. + while (messages.length < 10) { let record = await this.readRecord(); if (record === null) { @@ -1554,7 +1690,9 @@ export class TLSConnection { } } - return messages; + throw new TLSError( + `Server sent ${messages.length} handshake messages without Finished`, + ); } /** @@ -1648,6 +1786,14 @@ export class TLSConnection { const version = view.getUint16(1) as TLSVersion; const length = view.getUint16(3); + // TLS spec caps records at 2^14 + 256 bytes (16640) for encrypted records + const TLS_MAX_RECORD_LENGTH = 16640; + if (length > TLS_MAX_RECORD_LENGTH) { + throw new TLSError( + `Record length ${length} exceeds TLS maximum of ${TLS_MAX_RECORD_LENGTH} bytes`, + ); + } + // Read record data const data = await this.readExactly(length); @@ -1665,15 +1811,39 @@ export class TLSConnection { } /** - * Read exactly n bytes from socket, looping until all bytes received + * Read exactly n bytes from socket, looping until all bytes received. + * Uses an overall deadline to prevent slow-trickle attacks where a + * malicious server sends 1 byte at a time to hold the connection. */ private async readExactly(n: number): Promise { const buffer = new Uint8Array(n) as ByteBuffer; let offset = 0; + const READ_DEADLINE = 10_000; // 10 seconds total for the entire read + const deadline = Date.now() + READ_DEADLINE; while (offset < n) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new TLSError( + `Socket read timed out after ${READ_DEADLINE}ms (read ${offset}/${n} bytes)`, + ); + } + const chunk = buffer.subarray(offset) as ByteBuffer; - const bytesRead = await this.socket.read(chunk); + + // Race socket read against overall deadline + let readTimer: number | undefined; + let bytesRead: number | null; + try { + bytesRead = await Promise.race([ + this.socket.read(chunk), + new Promise((_, reject) => { + readTimer = setTimeout(() => reject(new TLSError(`Socket read timed out after ${READ_DEADLINE}ms (read ${offset}/${n} bytes)`)), remaining) as unknown as number; + }), + ]); + } finally { + if (readTimer !== undefined) clearTimeout(readTimer); + } if (bytesRead === null || bytesRead === 0) { if (offset === 0) { @@ -1710,8 +1880,50 @@ export class TLSConnection { private async sendAlert(alert: TLSAlert): Promise { const alertData = new Uint8Array([alert.level, alert.description]); - const record = createTLSRecord(TLSRecordType.ALERT, alertData); - await this.socket.write(serializeTLSRecord(record)); + + // After handshake, alerts must be encrypted per RFC 8446 Section 6 + if (this.state === TLSHandshakeState.ESTABLISHED && this.sessionKeys) { + if (this.negotiatedVersion === TLSVersion.TLS_1_2) { + // TLS 1.2: Encrypt alert as ALERT record type + const ciphertext = await this.encryptTLS12Record( + alertData, + TLSRecordType.ALERT, + ); + const record = createTLSRecord(TLSRecordType.APPLICATION_DATA, ciphertext as ByteBuffer); + await this.socket.write(serializeTLSRecord(record)); + } else { + // TLS 1.3: Inner plaintext format is [alert_data][content_type_ALERT] + const innerPlaintext = new Uint8Array(alertData.byteLength + 1); + innerPlaintext.set(alertData, 0); + innerPlaintext[alertData.byteLength] = TLSRecordType.ALERT; // 0x15 + + const ciphertextLength = innerPlaintext.byteLength + 16; // GCM tag + + // Construct AAD (record header) + const aad = new Uint8Array(5); + aad[0] = TLSRecordType.APPLICATION_DATA; // 0x17 — outer type is always APPLICATION_DATA + aad[1] = 0x03; + aad[2] = 0x03; + aad[3] = (ciphertextLength >> 8) & 0xFF; + aad[4] = ciphertextLength & 0xFF; + + const ciphertext = await encrypt( + innerPlaintext as ByteBuffer, + this.sessionKeys.clientWriteKey, + this.sessionKeys.clientWriteIV, + this.clientSequenceNumber++, + aad as ByteBuffer, + this.negotiatedCipherSuite, + ); + + const record = createTLSRecord(TLSRecordType.APPLICATION_DATA, ciphertext as ByteBuffer); + await this.socket.write(serializeTLSRecord(record)); + } + } else { + // Pre-handshake alerts are sent unencrypted + const record = createTLSRecord(TLSRecordType.ALERT, alertData); + await this.socket.write(serializeTLSRecord(record)); + } } /** @@ -1719,8 +1931,8 @@ export class TLSConnection { */ getInfo(): TLSConnectionInfo { return { - version: TLSVersion.TLS_1_3, - cipherSuite: "TLS_AES_128_GCM_SHA256", + version: this.negotiatedVersion || TLSVersion.TLS_1_3, + cipherSuite: CipherSuite[this.negotiatedCipherSuite] || "TLS_AES_128_GCM_SHA256", alpnProtocol: this.negotiatedProtocol, serverName: this.config.serverName, peerCertificate: this.peerCertificate, @@ -2141,17 +2353,9 @@ async function computeECDHESharedSecret( return new Uint8Array(sharedSecretBits); } catch { - // Fallback: derive using simple XOR-based combination - // This is for environments without X25519 support - const sharedSecret = new Uint8Array(32); - for (let i = 0; i < 32; i++) { - // Combine private and public key bytes - sharedSecret[i] = privateKey[i % privateKey.length] ^ - peerPublicKey[i % peerPublicKey.length]; - } - // Hash the result to ensure uniform distribution - const hashBuffer = await crypto.subtle.digest("SHA-256", sharedSecret); - return new Uint8Array(hashBuffer); + // Fallback: use pure-TypeScript X25519 Montgomery ladder (RFC 7748) + // when Web Crypto X25519 is unavailable (e.g., older Deno versions) + return x25519ScalarMult(privateKey, peerPublicKey); } } diff --git a/browser/src/engine/rendering/RenderingOrchestrator.ts b/browser/src/engine/rendering/RenderingOrchestrator.ts index 1d03a4a..45a7ce2 100644 --- a/browser/src/engine/rendering/RenderingOrchestrator.ts +++ b/browser/src/engine/rendering/RenderingOrchestrator.ts @@ -28,6 +28,14 @@ import { RenderingPipelineError } from "../RenderingPipeline.ts"; import { ResourceFetcher } from "./ResourceFetcher.ts"; import type { RequestPipeline } from "../RequestPipeline.ts"; import { RenderToPixels } from "./paint/RenderToPixels.ts"; +import { WindowRenderer, type PresentFrameInfo } from "./WindowRenderer.ts"; + +/** + * Approximate ascent-to-font-size ratio for baseline positioning. + * Most Latin fonts have an ascent of ~80% of em-square. Used when + * actual font metrics are unavailable. + */ +const DEFAULT_ASCENT_RATIO = 0.80; /** * RenderingOrchestrator runs the main render pipeline and manages the observer. @@ -43,6 +51,8 @@ export class RenderingOrchestrator { }; private csp?: ContentSecurityPolicy; private renderToPixels: RenderToPixels; + private windowRenderer: WindowRenderer | null = null; + private lastPresentFrameInfo: PresentFrameInfo | null = null; constructor( private resourceFetcher: ResourceFetcher, @@ -69,6 +79,23 @@ export class RenderingOrchestrator { return this.lastRenderArtifacts; } + /** + * Set a WindowRenderer to present frames after compositing. + * When set, each render() call will push pixels to the renderer. + */ + setWindowRenderer(renderer: WindowRenderer | null): void { + this.windowRenderer = renderer; + } + + getWindowRenderer(): WindowRenderer | null { + return this.windowRenderer; + } + + /** Get the frame info from the last present() call. */ + getLastPresentFrameInfo(): PresentFrameInfo | null { + return this.lastPresentFrameInfo; + } + setCSP(csp: ContentSecurityPolicy | undefined): void { this.csp = csp; this.resourceFetcher.setCSP(csp); @@ -203,10 +230,10 @@ export class RenderingOrchestrator { ); // 4.5. Execute JavaScript (if enabled) - this.emitStage("script-execution", "Script Execution", "running", Date.now()); + const scriptStart = Date.now(); + this.emitStage("script-execution", "Script Execution", "running", scriptStart); let scriptExecutor: ScriptExecutor | undefined; if (options.enableJavaScript ?? this.enableJavaScript) { - const scriptStart = Date.now(); scriptExecutor = new ScriptExecutor( dom, url.toString(), @@ -225,7 +252,7 @@ export class RenderingOrchestrator { "script-execution", "Script Execution", "completed", - Date.now(), + scriptStart, Date.now(), timing.scriptExecution, scriptExecutor, @@ -234,6 +261,8 @@ export class RenderingOrchestrator { // 5. Build Render Tree this.emitStage("style-resolution", "Style Resolution", "running", Date.now()); const styleStart = Date.now(); + // Set viewport dimensions on CSSOM for @media query evaluation + cssom.setViewport(this.width, this.height); const styleResolver = new StyleResolver(cssom); const renderTree = new RenderTree(); @@ -278,36 +307,44 @@ export class RenderingOrchestrator { layoutTree, ); - // 6.5. Fetch images + // 6.5. Fetch images (both tags and CSS background-image URLs) const enableImages = options.enableImages ?? true; let imageMap = new Map(); if (enableImages) { imageMap = await this.resourceFetcher.fetchImages(htmlResult, url, options.signal); + + // Also discover and fetch CSS background-image URLs from the render tree + const bgUrls = this.collectBackgroundImageUrls(rootRenderObject); + const newBgUrls = bgUrls.filter((bgUrl) => !imageMap.has(bgUrl)); + const bgResults = await Promise.allSettled( + newBgUrls.map(async (bgUrl) => { + const resolved = new URL(bgUrl, url); + const imgResult = await this.resourceFetcher.getRequestPipeline().get(resolved, { signal: options.signal }); + const imgData = imgResult.response.body; + try { + const contentType = imgResult.response.headers?.get("content-type") || "image/png"; + const blob = new Blob([imgData], { type: contentType }); + const bitmap = await createImageBitmap(blob as unknown as ImageBitmapSource); + return { bgUrl, image: bitmap as unknown as import("../../types/dom.ts").CanvasImageSource }; + } catch { + return { bgUrl, image: { width: 0, height: 0, close: () => {}, _data: imgData } as any }; + } + }), + ); + for (const result of bgResults) { + if (result.status === "fulfilled") { + imageMap.set(result.value.bgUrl, result.value.image); + } + } } // 7. Paint this.emitStage("paint", "Paint", "running", Date.now()); const paintStart = Date.now(); const displayList = new DisplayList(); - const paintContext = new PaintContext(); - this.paint(layoutTree, paintContext); - - for (const command of paintContext.getCommands()) { - const params = command.params && typeof command.params === "object" - ? command.params as Record - : {}; - const displayCommand = { - type: command.type, - ...params, - } as import("./paint/DisplayList.ts").AnyPaintCommand; - displayList.add(displayCommand); - } - - for (const [src, img] of imageMap) { - displayList.registerImage(src, img); - } - // Also paint through RenderToPixels for stacking-context-aware layer tree + // Paint through RenderToPixels for stacking-context-aware layer tree + // This is the primary paint pass used by the compositor const paintResult = this.renderToPixels.paint( rootRenderObject, this.width as Pixels, @@ -315,6 +352,44 @@ export class RenderingOrchestrator { false, ); + // Count nodes — only run the legacy PaintContext pass for smaller DOMs + // to populate the displayList artifact. For large DOMs the pass is skipped + // and displayListTruncated is set on the result. + const DISPLAY_LIST_NODE_THRESHOLD = options.displayListNodeThreshold ?? 5000; + const nodeCount = layoutEngine.getStats().totalNodes; + const displayListTruncated = nodeCount > DISPLAY_LIST_NODE_THRESHOLD; + if (!displayListTruncated) { + const paintContext = new PaintContext(); + this.paint(layoutTree, paintContext); + + for (const command of paintContext.getCommands()) { + const params = command.params && typeof command.params === "object" + ? command.params as Record + : {}; + const displayCommand = { + type: command.type, + ...params, + } as import("./paint/DisplayList.ts").AnyPaintCommand; + displayList.add(displayCommand); + } + } + + for (const [src, img] of imageMap) { + displayList.registerImage(src, img); + } + + // Register fetched images on all layer display lists so DRAW_IMAGE commands render + if (imageMap.size > 0) { + for (const layer of paintResult.layerTree.getAllLayers()) { + for (const [src, img] of imageMap) { + layer.getDisplayList().registerImage(src, img); + } + } + } + + // Upload paint layer tree to compositor for layer-aware compositing + this.compositor.updateLayerTree(paintResult.layerTree); + timing.paintRecording = Date.now() - paintStart; this.emitStage( "paint", @@ -326,7 +401,7 @@ export class RenderingOrchestrator { displayList, ); - // 7.5. Pass render tree to compositor for CPU rendering + // 7.5. Pass render tree to compositor as fallback for CPU rendering if (this.compositor.isCPUMode()) { this.compositor.setRenderTree(rootRenderObject); } @@ -345,6 +420,28 @@ export class RenderingOrchestrator { timing.compositing, ); + // 9. Present — push pixels to native window or offscreen buffer + if (this.windowRenderer && this.windowRenderer.isRunning()) { + this.emitStage("present", "Present", "running", Date.now()); + const presentStart = Date.now(); + const pixels = await this.compositor.getPixels(); + this.lastPresentFrameInfo = this.windowRenderer.present( + pixels, + this.width, + this.height, + ); + const presentDuration = Date.now() - presentStart; + this.emitStage( + "present", + "Present", + "completed", + presentStart, + Date.now(), + presentDuration, + this.lastPresentFrameInfo, + ); + } + timing.total = Date.now() - startTime; const result: RenderingResult = { @@ -353,6 +450,7 @@ export class RenderingOrchestrator { renderTree, layoutTree, displayList, + displayListTruncated, layerTree: paintResult.layerTree, scriptExecutor, timing: timing as RenderingTiming, @@ -435,17 +533,58 @@ export class RenderingOrchestrator { } } - // Background + // Parse border-radius for this element + const borderRadiusStr = style?.getPropertyValue("border-radius"); + let radii: [number, number, number, number] | null = null; + if (borderRadiusStr && borderRadiusStr !== "0" && borderRadiusStr !== "0px") { + const parts = borderRadiusStr.split(/\s+/).map((p: string) => parseFloat(p) || 0); + if (parts.some((p: number) => p > 0)) { + if (parts.length === 1) radii = [parts[0], parts[0], parts[0], parts[0]]; + else if (parts.length === 2) radii = [parts[0], parts[1], parts[0], parts[1]]; + else if (parts.length === 3) radii = [parts[0], parts[1], parts[2], parts[1]]; + else radii = [parts[0], parts[1], parts[2], parts[3]]; + } + } + + // Background color if (style) { const bgColor = style.getPropertyValue("background-color"); if (bgColor && bgColor !== "transparent") { - context.fillRect( - layoutBox.x, - layoutBox.y, - layoutBox.width, - layoutBox.height, - bgColor, - ); + if (radii) { + context.fillRoundedRect( + layoutBox.x, + layoutBox.y, + layoutBox.width, + layoutBox.height, + bgColor, + radii, + ); + } else { + context.fillRect( + layoutBox.x, + layoutBox.y, + layoutBox.width, + layoutBox.height, + bgColor, + ); + } + } + } + + // Background image + if (style) { + const bgImage = style.getPropertyValue("background-image"); + if (bgImage && bgImage !== "none") { + const urlMatch = bgImage.match(/url\(["']?([^"')]+)["']?\)/); + if (urlMatch) { + context.drawImage( + urlMatch[1], + layoutBox.x, + layoutBox.y, + layoutBox.width, + layoutBox.height, + ); + } } } @@ -458,14 +597,26 @@ export class RenderingOrchestrator { if (borderColor && borderWidthStr) { const borderWidth = parseFloat(borderWidthStr) as Pixels; if (borderWidth > 0) { - context.strokeRect( - layoutBox.x, - layoutBox.y, - layoutBox.width, - layoutBox.height, - borderColor, - borderWidth, - ); + if (radii) { + context.strokeRoundedRect( + layoutBox.x, + layoutBox.y, + layoutBox.width, + layoutBox.height, + borderColor, + borderWidth, + radii, + ); + } else { + context.strokeRect( + layoutBox.x, + layoutBox.y, + layoutBox.width, + layoutBox.height, + borderColor, + borderWidth, + ); + } } } } @@ -485,7 +636,8 @@ export class RenderingOrchestrator { if (layoutBox.type === "text" && layoutBox.text) { const color = style?.getPropertyValue("color") || "#000000"; const fontSizeStr = style?.getPropertyValue("font-size"); - const fontSize = fontSizeStr ? parseFloat(fontSizeStr) : 16; + const fontSizeParsed = fontSizeStr ? parseFloat(fontSizeStr) : 16; + const fontSize = Number.isFinite(fontSizeParsed) ? fontSizeParsed : 16; const fontFamily = style?.getPropertyValue("font-family") || "sans-serif"; const font = `${fontSize}px ${fontFamily}`; @@ -503,10 +655,13 @@ export class RenderingOrchestrator { } } + // layoutBox.y is the box top; canvas fillText expects baseline y. + // Baseline = box top + ascent (~80% of fontSize). + const baseline = (layoutBox.y as number) + fontSize * DEFAULT_ASCENT_RATIO; context.fillText( layoutBox.text, layoutBox.x, - layoutBox.y, + baseline as Pixels, font, color, ); @@ -556,36 +711,13 @@ export class RenderingOrchestrator { } /** - * Parse a box-shadow or text-shadow CSS value - * Simplified: handles "offsetX offsetY blur color" format - */ - private parseBoxShadow( - value: string, - ): { offsetX: number; offsetY: number; blur: number; color: string } | null { - // Match patterns like "2px 2px 4px rgba(0,0,0,0.5)" or "2px 2px 4px #000" - const match = value.match( - /(-?\d+(?:\.\d+)?)\s*px\s+(-?\d+(?:\.\d+)?)\s*px\s+(-?\d+(?:\.\d+)?)\s*px\s+(.*)/, - ); - if (match) { - return { - offsetX: parseFloat(match[1]), - offsetY: parseFloat(match[2]), - blur: parseFloat(match[3]), - color: match[4].trim(), - }; - } - return null; - } - - /** - * Parse a text-shadow CSS value - * Supports 2-length (offsetX offsetY color) and 3-length (offsetX offsetY blur color) forms. - * Only parses the first shadow if comma-separated multiples are present. + * Parse a shadow CSS value (shared by box-shadow and text-shadow). + * Handles 3-length "offsetX offsetY blur color" and 2-length "offsetX offsetY color". + * Takes the first comma-separated shadow if multiples are present. */ - private parseTextShadow( + private parseShadowValue( value: string, ): { offsetX: number; offsetY: number; blur: number; color: string } | null { - // Take only the first shadow if multiple are specified const firstShadow = value.split(",")[0].trim(); // Try 3-length: offsetX offsetY blur color @@ -616,4 +748,37 @@ export class RenderingOrchestrator { return null; } + + private parseBoxShadow( + value: string, + ): { offsetX: number; offsetY: number; blur: number; color: string } | null { + return this.parseShadowValue(value); + } + + private parseTextShadow( + value: string, + ): { offsetX: number; offsetY: number; blur: number; color: string } | null { + return this.parseShadowValue(value); + } + + /** + * Collect all CSS background-image URLs from the render tree. + */ + private collectBackgroundImageUrls(root: import("./rendering/RenderObject.ts").RenderObject): string[] { + const urls: string[] = []; + const visit = (obj: import("./rendering/RenderObject.ts").RenderObject) => { + const bgImage = obj.style.getPropertyValue("background-image"); + if (bgImage && bgImage !== "none") { + const urlMatch = bgImage.match(/url\(["']?([^"')]+)["']?\)/); + if (urlMatch) { + urls.push(urlMatch[1]); + } + } + for (const child of obj.children) { + visit(child); + } + }; + visit(root); + return urls; + } } diff --git a/browser/src/engine/rendering/ResourceFetcher.ts b/browser/src/engine/rendering/ResourceFetcher.ts index 101f3d5..f79fc0f 100644 --- a/browser/src/engine/rendering/ResourceFetcher.ts +++ b/browser/src/engine/rendering/ResourceFetcher.ts @@ -254,11 +254,21 @@ export class ResourceFetcher { try { const styleElements = this.findStyleElements(dom); + // Collect inline styles immediately, build fetch promises for external sheets + const fetchPromises: Array<{ index: number; promise: Promise }> = []; + let orderIndex = 0; + for (const element of styleElements) { if (element.tagName === "link") { + // Only fetch rel="stylesheet" links (skip preload, icon, etc.) + const rel = element.attributes.get("rel"); + if (rel && rel.toLowerCase() !== "stylesheet") { + continue; + } const href = element.attributes.get("href"); if (href) { - const cssUrl = new URL(href, baseUrl); + const cssUrl = (() => { try { return new URL(href, baseUrl); } catch { return null; } })(); + if (!cssUrl) continue; if (this.csp) { const pageOrigin = new URL(baseUrl.toString()).origin; @@ -268,27 +278,55 @@ export class ResourceFetcher { } } - const result = await this.requestPipeline.get(cssUrl); + // Check media attribute — skip if media query won't match screen + const media = element.attributes.get("media"); + if (media && media !== "all" && media !== "screen" && !media.includes("screen")) { + if (media === "print" || media.startsWith("print")) { + continue; + } + } - this.resources.push({ - url: result.request.url, - type: "css", - size: result.response.body.byteLength, - fetchTime: result.timing.total, - cached: result.fromCache, + const idx = orderIndex++; + fetchPromises.push({ + index: idx, + promise: (async () => { + const result = await this.requestPipeline.get(cssUrl); + this.resources.push({ + url: result.request.url, + type: "css", + size: result.response.body.byteLength, + fetchTime: result.timing.total, + cached: result.fromCache, + }); + return new TextDecoder().decode(result.response.body); + })(), }); - - const cssText = new TextDecoder().decode(result.response.body); - stylesheets.push(cssText); } } else if (element.tagName === "style") { const textContent = this.getTextContent(element); if (textContent) { - stylesheets.push(textContent); + // Inline styles go at their position in document order + const idx = orderIndex++; + fetchPromises.push({ index: idx, promise: Promise.resolve(textContent) }); } } } + // Fetch all external stylesheets in parallel, preserving document order + const results = await Promise.allSettled(fetchPromises.map(fp => fp.promise)); + for (let i = 0; i < results.length; i++) { + const result = results[i]; + if (result.status === "fulfilled" && result.value) { + stylesheets.push(result.value); + } else if (result.status === "rejected") { + this.logger.warn(`Failed to fetch stylesheet: ${(result.reason as Error)?.message}`); + } + } + + // Fetch @import rules from collected stylesheets + const importedSheets = await this.fetchCSSImports(stylesheets, baseUrl); + stylesheets.push(...importedSheets); + return stylesheets; } catch (error) { this.logger.warn("Failed to fetch some stylesheets:", error); @@ -296,6 +334,43 @@ export class ResourceFetcher { } } + /** + * Fetch CSS @import rules recursively (max 3 levels deep) + */ + private async fetchCSSImports( + stylesheets: string[], + baseUrl: string | URL, + depth: number = 0, + ): Promise { + if (depth >= 3) return []; // Prevent infinite recursion + + const imported: string[] = []; + const importRegex = /@import\s+(?:url\(\s*["']?([^"')]+)["']?\s*\)|["']([^"']+)["'])/g; + + for (const css of stylesheets) { + let match; + while ((match = importRegex.exec(css)) !== null) { + const importUrl = match[1] || match[2]; + if (!importUrl) continue; + + try { + const resolvedUrl = new URL(importUrl, baseUrl); + const result = await this.requestPipeline.get(resolvedUrl); + const importedCSS = new TextDecoder().decode(result.response.body); + imported.push(importedCSS); + + // Recursively fetch nested @imports + const nested = await this.fetchCSSImports([importedCSS], resolvedUrl.toString(), depth + 1); + imported.push(...nested); + } catch { + this.logger.warn(`Failed to fetch @import: ${importUrl}`); + } + } + } + + return imported; + } + /** * Parse CSS to CSSOM */ @@ -304,11 +379,16 @@ export class ResourceFetcher { const cssom = new CSSOM(); for (const css of stylesheets) { - const tokenizer = new CSSTokenizer(); - const tokens = tokenizer.tokenize(css); - const parser = new CSSParser(); - const stylesheet = parser.parse(tokens); - cssom.addStyleSheet(stylesheet); + try { + const tokenizer = new CSSTokenizer(); + const tokens = tokenizer.tokenize(css); + const parser = new CSSParser(); + const stylesheet = parser.parse(tokens); + cssom.addStyleSheet(stylesheet); + } catch (cssErr) { + this.logger.warn(`Failed to parse CSS: ${(cssErr as Error).message}`); + // Skip this stylesheet but continue with others + } } return cssom; diff --git a/browser/src/engine/rendering/WindowRenderer.ts b/browser/src/engine/rendering/WindowRenderer.ts new file mode 100644 index 0000000..37c49c3 --- /dev/null +++ b/browser/src/engine/rendering/WindowRenderer.ts @@ -0,0 +1,200 @@ +/** + * WindowRenderer — Bridge between compositor pixel output and display targets. + * + * Supports two modes: + * 1. **Native** — Sends pixels to a pixpane window via FFI (create_window → upload → render). + * 2. **Offscreen** — Stores the latest pixel buffer in memory for programmatic access + * (screenshots, MCP tools, tests). + * + * After `RenderingOrchestrator.render()` completes the composite step the caller + * invokes `present(pixels, width, height)` which routes to the active target. + */ + +import { Window, type WindowConfig, type WindowEvent } from "../../os/window/mod.ts"; +import { WindowContext, type WindowContextConfig } from "../../os/window/mod.ts"; +import { BrowserConsole } from "../logging/BrowserConsole.ts"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type DisplayMode = "native" | "offscreen"; + +export interface WindowRendererConfig { + /** Display mode — "native" opens a pixpane window, "offscreen" stores pixels in memory. */ + mode: DisplayMode; + /** Window title (native mode). */ + title?: string; + /** Viewport width in logical pixels. */ + width: number; + /** Viewport height in logical pixels. */ + height: number; + /** Enable vsync in native mode. */ + vsync?: boolean; + /** Whether the native window is resizable. */ + resizable?: boolean; +} + +export interface PresentFrameInfo { + /** Frame sequence number. */ + frameNumber: number; + /** Time spent presenting the frame (ms). */ + presentTime: number; +} + +// --------------------------------------------------------------------------- +// WindowRenderer +// --------------------------------------------------------------------------- + +export class WindowRenderer { + private logger = new BrowserConsole("WindowRenderer"); + private config: WindowRendererConfig; + private window: Window | null = null; + private windowContext: WindowContext | null = null; + private frameNumber = 0; + private running = false; + + /** Offscreen pixel buffer — always kept up-to-date regardless of mode. */ + private offscreenBuffer: Uint8ClampedArray | null = null; + private offscreenWidth = 0; + private offscreenHeight = 0; + + /** Accumulated events from poll_event (native mode). */ + private pendingEvents: WindowEvent[] = []; + + constructor(config: WindowRendererConfig) { + this.config = { ...config }; + } + + // ----------------------------------------------------------------------- + // Lifecycle + // ----------------------------------------------------------------------- + + /** + * Initialize the renderer. In native mode this opens the pixpane window. + * In offscreen mode this is essentially a no-op (prepares the buffer). + */ + async initialize(): Promise { + if (this.running) return; + + if (this.config.mode === "native") { + const windowConfig: WindowConfig = { + title: this.config.title ?? "BrowserX", + width: this.config.width, + height: this.config.height, + resizable: this.config.resizable ?? true, + visible: true, + }; + + this.window = new Window(windowConfig); + await this.window.open(); + + const contextConfig: WindowContextConfig = { + vsync: this.config.vsync ?? true, + }; + this.windowContext = new WindowContext(this.window, contextConfig); + await this.windowContext.initialize(); + + if (this.window.isHeadlessMode()) { + this.logger.warn( + "Pixpane FFI unavailable — falling back to offscreen mode", + ); + } + } + + this.running = true; + this.logger.info( + `Initialized in ${this.config.mode} mode (${this.config.width}x${this.config.height})`, + ); + } + + /** + * Present a frame. Stores pixels in the offscreen buffer *and* pushes to + * the native window when in native mode. + */ + present(pixels: Uint8ClampedArray, width: number, height: number): PresentFrameInfo { + const start = performance.now(); + + // Always store in offscreen buffer + this.offscreenBuffer = pixels; + this.offscreenWidth = width; + this.offscreenHeight = height; + + // Push to native window if available + if (this.windowContext && this.window && !this.window.isHeadlessMode()) { + this.windowContext.present(pixels, width, height); + } + + this.frameNumber++; + const presentTime = performance.now() - start; + + return { frameNumber: this.frameNumber, presentTime }; + } + + /** + * Poll pending window events (native mode only). Returns an empty array in + * offscreen mode. + */ + async pollEvents(): Promise { + if (!this.window || this.window.isHeadlessMode()) { + return []; + } + + return this.window.pollEvents(); + } + + /** + * Destroy the renderer and release all resources. + */ + destroy(): void { + if (this.windowContext) { + this.windowContext.destroy(); + this.windowContext = null; + } + if (this.window) { + this.window.close(); + this.window = null; + } + this.offscreenBuffer = null; + this.running = false; + this.logger.info("Destroyed"); + } + + // ----------------------------------------------------------------------- + // Accessors + // ----------------------------------------------------------------------- + + /** Get the latest pixel buffer (available in both modes). */ + getPixels(): Uint8ClampedArray | null { + return this.offscreenBuffer; + } + + /** Dimensions of the latest pixel buffer. */ + getPixelDimensions(): { width: number; height: number } { + return { width: this.offscreenWidth, height: this.offscreenHeight }; + } + + /** Current display mode. */ + getMode(): DisplayMode { + return this.config.mode; + } + + /** Whether the renderer is initialized and running. */ + isRunning(): boolean { + return this.running; + } + + /** Current frame number. */ + getFrameNumber(): number { + return this.frameNumber; + } + + /** Resize the viewport. Updates the native window if applicable. */ + resize(width: number, height: number): void { + this.config.width = width; + this.config.height = height; + if (this.window && !this.window.isHeadlessMode()) { + this.window.resize(width, height); + } + } +} diff --git a/browser/src/engine/rendering/compositor/CompositorLayer.ts b/browser/src/engine/rendering/compositor/CompositorLayer.ts index a2a1af2..5759217 100644 --- a/browser/src/engine/rendering/compositor/CompositorLayer.ts +++ b/browser/src/engine/rendering/compositor/CompositorLayer.ts @@ -146,7 +146,7 @@ export class CompositorLayer { const bounds = this.paintLayer.getBounds(); // Create canvas and render display list - const canvas = document.createElement("canvas"); + const canvas = document.createElement("canvas") as unknown as HTMLCanvasElement; canvas.width = bounds.width; canvas.height = bounds.height; diff --git a/browser/src/engine/rendering/compositor/CompositorThread.ts b/browser/src/engine/rendering/compositor/CompositorThread.ts index 46df07d..eb1473c 100644 --- a/browser/src/engine/rendering/compositor/CompositorThread.ts +++ b/browser/src/engine/rendering/compositor/CompositorThread.ts @@ -411,28 +411,39 @@ export class CompositorThread { throw new Error("[CompositorThread] composite() called before initialization"); } - // CPU rendering mode - use RenderToPixels + // CPU rendering mode - use LayerTree compositing or RenderToPixels fallback if (this.cpuRenderMode) { - if (!this.renderToPixels) { - throw new Error("[CompositorThread] CPU mode but RenderToPixels not initialized"); - } + if (this.layerTree) { + // Layer-aware path: composite the paint layer tree via Canvas 2D + const ctx = this.canvas.getContext("2d"); + if (!ctx) { + throw new Error("[CompositorThread] Failed to get 2D context for CPU composite"); + } + ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.layerTree.paintDirtyLayers(); + this.layerTree.composite(ctx); + this.cpuCanvas = this.canvas; + } else { + // Fallback: re-paint from render tree via RenderToPixels + if (!this.renderToPixels) { + throw new Error("[CompositorThread] CPU mode but RenderToPixels not initialized"); + } - if (!this.lastRenderObject) { - throw new Error( - "[CompositorThread] CPU mode but no render tree set - call setRenderTree() before composite()", - ); - } + if (!this.lastRenderObject) { + throw new Error( + "[CompositorThread] CPU mode but no render tree set - call setRenderTree() before composite()", + ); + } - // Paint render tree to Canvas 2D - let errors propagate - const paintResult = this.renderToPixels.paint( - this.lastRenderObject, - this.canvas.width as Pixels, - this.canvas.height as Pixels, - false, // full repaint - ); + const paintResult = this.renderToPixels.paint( + this.lastRenderObject, + this.canvas.width as Pixels, + this.canvas.height as Pixels, + false, + ); - // Store result canvas for getPixels() extraction - this.cpuCanvas = paintResult.canvas; + this.cpuCanvas = paintResult.canvas; + } this.frameCount++; return; @@ -606,8 +617,8 @@ export class CompositorThread { // CPU rendering mode - extract pixels from Canvas 2D if (this.cpuRenderMode) { if (!this.cpuCanvas) { - // No composite() was called yet — attempt one now if we have a render tree - if (this.lastRenderObject) { + // No composite() was called yet — attempt one now if we have data + if (this.layerTree || this.lastRenderObject) { this.composite(); } if (!this.cpuCanvas) { diff --git a/browser/src/engine/rendering/compositor/Tile.ts b/browser/src/engine/rendering/compositor/Tile.ts index a981592..66aeb04 100644 --- a/browser/src/engine/rendering/compositor/Tile.ts +++ b/browser/src/engine/rendering/compositor/Tile.ts @@ -184,7 +184,7 @@ export class Tile { * Create canvas for rasterization */ private createCanvas(): HTMLCanvasElement { - const canvas = document.createElement("canvas"); + const canvas = document.createElement("canvas") as unknown as HTMLCanvasElement; canvas.width = Math.ceil(this.bounds.width * this.scale); canvas.height = Math.ceil(this.bounds.height * this.scale); return canvas; diff --git a/browser/src/engine/rendering/css-parser/CSSOM.ts b/browser/src/engine/rendering/css-parser/CSSOM.ts index 5776b82..61fdc8f 100644 --- a/browser/src/engine/rendering/css-parser/CSSOM.ts +++ b/browser/src/engine/rendering/css-parser/CSSOM.ts @@ -138,6 +138,26 @@ export class CSSOM { origin: entry.origin, }); } + + // Evaluate inline @media rules from the stylesheet + if (entry.stylesheet.mediaRules) { + for (const mediaRule of entry.stylesheet.mediaRules) { + if (this.matchesMediaQuery(mediaRule.condition)) { + for (const rule of mediaRule.rules) { + // Check if rule matches this element + for (const selector of rule.selectorList) { + if (selector.matches(element)) { + matchingRules.push({ + rule, + origin: entry.origin, + }); + break; + } + } + } + } + } + } } // Sort by cascade order: diff --git a/browser/src/engine/rendering/css-parser/CSSParser.ts b/browser/src/engine/rendering/css-parser/CSSParser.ts index f6bba39..72c0cbc 100644 --- a/browser/src/engine/rendering/css-parser/CSSParser.ts +++ b/browser/src/engine/rendering/css-parser/CSSParser.ts @@ -651,6 +651,7 @@ class StyleSheet implements CSSStyleSheet { href: string | null = null; ownerNode: DOMElement | null = null; rules: CSSRule[] = []; + mediaRules: import("../../../types/css.ts").CSSMediaRule[] = []; disabled: boolean = false; insertRule(ruleText: string, index: number = this.rules.length): number { @@ -779,6 +780,9 @@ export class CSSParser { } } + // Copy parsed @media rules to the stylesheet so CSSOM can evaluate them + stylesheet.mediaRules = [...this.mediaRules]; + return stylesheet; } diff --git a/browser/src/engine/rendering/html-parser/HTMLTokenizer.ts b/browser/src/engine/rendering/html-parser/HTMLTokenizer.ts index 728b0ff..ddbdba9 100644 --- a/browser/src/engine/rendering/html-parser/HTMLTokenizer.ts +++ b/browser/src/engine/rendering/html-parser/HTMLTokenizer.ts @@ -10,6 +10,58 @@ * thousands of intermediate string allocations. */ +/** + * Common HTML named character entities. + */ +const NAMED_ENTITIES: Record = { + nbsp: "\u00A0", lt: "<", gt: ">", amp: "&", quot: '"', apos: "'", + copy: "\u00A9", reg: "\u00AE", trade: "\u2122", euro: "\u20AC", + pound: "\u00A3", yen: "\u00A5", cent: "\u00A2", sect: "\u00A7", + deg: "\u00B0", plusmn: "\u00B1", micro: "\u00B5", para: "\u00B6", + middot: "\u00B7", frac12: "\u00BD", frac14: "\u00BC", frac34: "\u00BE", + times: "\u00D7", divide: "\u00F7", laquo: "\u00AB", raquo: "\u00BB", + ndash: "\u2013", mdash: "\u2014", lsquo: "\u2018", rsquo: "\u2019", + ldquo: "\u201C", rdquo: "\u201D", bull: "\u2022", hellip: "\u2026", + prime: "\u2032", Prime: "\u2033", larr: "\u2190", rarr: "\u2192", + uarr: "\u2191", darr: "\u2193", harr: "\u2194", crarr: "\u21B5", + loz: "\u25CA", spades: "\u2660", clubs: "\u2663", hearts: "\u2665", + diams: "\u2666", ensp: "\u2002", emsp: "\u2003", thinsp: "\u2009", + zwnj: "\u200C", zwj: "\u200D", lrm: "\u200E", rlm: "\u200F", + iexcl: "\u00A1", brvbar: "\u00A6", uml: "\u00A8", ordf: "\u00AA", + not: "\u00AC", shy: "\u00AD", macr: "\u00AF", sup1: "\u00B9", + sup2: "\u00B2", sup3: "\u00B3", acute: "\u00B4", cedil: "\u00B8", + ordm: "\u00BA", iquest: "\u00BF", + Agrave: "\u00C0", Aacute: "\u00C1", Acirc: "\u00C2", Atilde: "\u00C3", + Auml: "\u00C4", Aring: "\u00C5", AElig: "\u00C6", Ccedil: "\u00C7", + Egrave: "\u00C8", Eacute: "\u00C9", Ecirc: "\u00CA", Euml: "\u00CB", + Igrave: "\u00CC", Iacute: "\u00CD", Icirc: "\u00CE", Iuml: "\u00CF", + ETH: "\u00D0", Ntilde: "\u00D1", Ograve: "\u00D2", Oacute: "\u00D3", + Ocirc: "\u00D4", Otilde: "\u00D5", Ouml: "\u00D6", Oslash: "\u00D8", + Ugrave: "\u00D9", Uacute: "\u00DA", Ucirc: "\u00DB", Uuml: "\u00DC", + Yacute: "\u00DD", THORN: "\u00DE", szlig: "\u00DF", + agrave: "\u00E0", aacute: "\u00E1", acirc: "\u00E2", atilde: "\u00E3", + auml: "\u00E4", aring: "\u00E5", aelig: "\u00E6", ccedil: "\u00E7", + egrave: "\u00E8", eacute: "\u00E9", ecirc: "\u00EA", euml: "\u00EB", + igrave: "\u00EC", iacute: "\u00ED", icirc: "\u00EE", iuml: "\u00EF", + eth: "\u00F0", ntilde: "\u00F1", ograve: "\u00F2", oacute: "\u00F3", + ocirc: "\u00F4", otilde: "\u00F5", ouml: "\u00F6", oslash: "\u00F8", + ugrave: "\u00F9", uacute: "\u00FA", ucirc: "\u00FB", uuml: "\u00FC", + yacute: "\u00FD", thorn: "\u00FE", yuml: "\u00FF", + Alpha: "\u0391", Beta: "\u0392", Gamma: "\u0393", Delta: "\u0394", + Epsilon: "\u0395", Zeta: "\u0396", Eta: "\u0397", Theta: "\u0398", + Iota: "\u0399", Kappa: "\u039A", Lambda: "\u039B", Mu: "\u039C", + Nu: "\u039D", Xi: "\u039E", Omicron: "\u039F", Pi: "\u03A0", + Rho: "\u03A1", Sigma: "\u03A3", Tau: "\u03A4", Upsilon: "\u03A5", + Phi: "\u03A6", Chi: "\u03A7", Psi: "\u03A8", Omega: "\u03A9", + alpha: "\u03B1", beta: "\u03B2", gamma: "\u03B3", delta: "\u03B4", + epsilon: "\u03B5", zeta: "\u03B6", eta: "\u03B7", theta: "\u03B8", + iota: "\u03B9", kappa: "\u03BA", lambda: "\u03BB", mu: "\u03BC", + nu: "\u03BD", xi: "\u03BE", omicron: "\u03BF", pi: "\u03C0", + rho: "\u03C1", sigmaf: "\u03C2", sigma: "\u03C3", tau: "\u03C4", + upsilon: "\u03C5", phi: "\u03C6", chi: "\u03C7", psi: "\u03C8", + omega: "\u03C9", +}; + export enum HTMLTokenType { DOCTYPE, START_TAG, @@ -1024,9 +1076,28 @@ export class HTMLTokenizer { this.emitCharacterToken(String.fromCodePoint(codePoint)); } } else { - // Named character reference — emit '&' literally and let the rest be parsed normally - // (Full named entity support would require a large lookup table) - this.emitCharacterToken("&"); + // Named character reference + let name = ""; + const startPos = this.position; + while (this.position < this.input.length) { + const c = this.input[this.position]; + if (c === ";") { + this.position++; + break; + } + if (!this.isAlpha(c) && !(c >= "0" && c <= "9")) break; + name += c; + this.position++; + } + + const decoded = NAMED_ENTITIES[name] ?? NAMED_ENTITIES[name.toLowerCase()]; + if (decoded !== undefined) { + this.emitCharacterToken(decoded); + } else { + // Not a known entity — emit literally + this.emitCharacterToken("&"); + this.position = startPos; // re-parse the name chars normally + } } } diff --git a/browser/src/engine/rendering/html-parser/HTMLTreeBuilder.ts b/browser/src/engine/rendering/html-parser/HTMLTreeBuilder.ts index a03076e..cdc4eef 100644 --- a/browser/src/engine/rendering/html-parser/HTMLTreeBuilder.ts +++ b/browser/src/engine/rendering/html-parser/HTMLTreeBuilder.ts @@ -62,6 +62,23 @@ class SimpleDOMNode { this.nodeId = nodeId; } + getAttribute(name: string): string | null { + return this.attributes?.get(name) ?? null; + } + + setAttribute(name: string, value: string): void { + if (!this.attributes) this.attributes = new Map(); + this.attributes.set(name, value); + } + + hasAttribute(name: string): boolean { + return this.attributes?.has(name) ?? false; + } + + removeAttribute(name: string): void { + this.attributes?.delete(name); + } + appendChild(child: SimpleDOMNode): SimpleDOMNode { child.parentNode = this; diff --git a/browser/src/engine/rendering/layout/LayoutEngine.ts b/browser/src/engine/rendering/layout/LayoutEngine.ts index 55d37a0..5634410 100644 --- a/browser/src/engine/rendering/layout/LayoutEngine.ts +++ b/browser/src/engine/rendering/layout/LayoutEngine.ts @@ -103,18 +103,23 @@ export class LayoutEngine { * Dispatches to appropriate layout algorithm based on display type */ private layoutNode(node: RenderObject, constraints: LayoutConstraints): void { - // Skip if node doesn't need layout - if (!node.needsLayout) { + // Skip only if this node AND its descendants are all clean + if (!node.needsLayout && !node.hasDescendantsNeedingLayout) { return; } - // Perform layout - node.doLayout(constraints); + if (node.needsLayout) { + // Perform layout on this node + node.doLayout(constraints); + } - // Layout children based on display type + // Layout children based on display type (recurse even if only descendants are dirty) if (node.children.length > 0) { this.layoutChildren(node, constraints); } + + // Clear descendant flag after children have been laid out + node.hasDescendantsNeedingLayout = false; } /** @@ -141,6 +146,20 @@ export class LayoutEngine { this.layoutTableChildren(parent, constraints); break; + case "table-row": + case "table-row-group": + case "table-header-group": + case "table-footer-group": + // Table rows/groups are laid out by the parent table's layoutTable() + // Don't re-layout as block — just recurse into cell contents + break; + + case "table-cell": + // Table cells are positioned by the parent table layout + // Layout their inner content as block flow + this.layoutBlockChildren(parent, constraints); + break; + case "block": case "flow-root": this.layoutBlockChildren(parent, constraints); @@ -160,16 +179,14 @@ export class LayoutEngine { // Recurse into children that have their own children needing layout for (const child of parent.children) { if (child.children.length > 0 && child.layout) { + // Skip children whose subtree is clean (O(1) check via propagated flag) + if (!child.needsLayout && !child.hasDescendantsNeedingLayout) { + continue; + } const childConstraints = this.getConstraintsForNode(child, { width: constraints.maxWidth, height: constraints.maxHeight, }); - // Ensure each grandchild gets doLayout called, then recurse - for (const grandchild of child.children) { - if (grandchild.needsLayout) { - grandchild.doLayout(childConstraints); - } - } this.layoutChildren(child, childConstraints); } } @@ -181,7 +198,7 @@ export class LayoutEngine { .filter((c) => c.layout !== null) .map((c) => c.layout!); if (childLayouts.length > 0) { - (parent.layout as any).children = childLayouts; + parent.layout.children = childLayouts; } } } @@ -302,15 +319,22 @@ export class LayoutEngine { * Reflow a subtree */ private reflowSubtree(node: RenderObject, viewport: ViewportSize): void { + if (!node.needsLayout && !node.hasDescendantsNeedingLayout) { + return; // Skip entire clean subtree + } + if (node.needsLayout) { // Get constraints from parent or viewport const constraints = this.getConstraintsForNode(node, viewport); this.layoutNode(node, constraints); } - // Recursively reflow children - for (const child of node.children) { - this.reflowSubtree(child, viewport); + // Recursively reflow children only if descendants are dirty + if (node.hasDescendantsNeedingLayout) { + for (const child of node.children) { + this.reflowSubtree(child, viewport); + } + node.hasDescendantsNeedingLayout = false; } } @@ -323,10 +347,10 @@ export class LayoutEngine { const parentLayout = node.parent.layout; return { minWidth: 0 as Pixels, - maxWidth: (parentLayout.width - parentLayout.paddingLeft - + maxWidth: Math.max(0, parentLayout.width - parentLayout.paddingLeft - parentLayout.paddingRight) as Pixels, minHeight: 0 as Pixels, - maxHeight: (parentLayout.height - parentLayout.paddingTop - + maxHeight: Math.max(0, parentLayout.height - parentLayout.paddingTop - parentLayout.paddingBottom) as Pixels, }; } @@ -380,6 +404,7 @@ export class LayoutEngine { clearLayoutFlags(root: RenderObject): void { this.visitTree(root, (node) => { node.needsLayout = false; + node.hasDescendantsNeedingLayout = false; }); } @@ -440,13 +465,16 @@ export class LayoutEngine { * Check if layout is stable (no more reflows needed) */ isLayoutStable(root: RenderObject): boolean { - let stable = true; - this.visitTree(root, (node) => { - if (node.needsLayout) { - stable = false; - } - }); - return stable; + return this.isSubtreeStable(root); + } + + private isSubtreeStable(node: RenderObject): boolean { + if (node.needsLayout) return false; + if (!node.hasDescendantsNeedingLayout) return true; + for (const child of node.children) { + if (!this.isSubtreeStable(child)) return false; + } + return true; } /** diff --git a/browser/src/engine/rendering/paint/DisplayList.ts b/browser/src/engine/rendering/paint/DisplayList.ts index 8e0129b..ddbb893 100644 --- a/browser/src/engine/rendering/paint/DisplayList.ts +++ b/browser/src/engine/rendering/paint/DisplayList.ts @@ -195,6 +195,33 @@ export interface SetOpacityCommand extends PaintCommand { alpha: number; } +/** + * Fill rounded rectangle command + */ +export interface FillRoundedRectCommand extends PaintCommand { + type: PaintCommandType.FILL_ROUNDED_RECT; + x: Pixels; + y: Pixels; + width: Pixels; + height: Pixels; + color: string; + radii: [number, number, number, number]; // [topLeft, topRight, bottomRight, bottomLeft] +} + +/** + * Stroke rounded rectangle command + */ +export interface StrokeRoundedRectCommand extends PaintCommand { + type: PaintCommandType.STROKE_ROUNDED_RECT; + x: Pixels; + y: Pixels; + width: Pixels; + height: Pixels; + color: string; + lineWidth: Pixels; + radii: [number, number, number, number]; // [topLeft, topRight, bottomRight, bottomLeft] +} + /** * Union of all command types */ @@ -217,7 +244,9 @@ export type AnyPaintCommand = | SetGlobalAlphaCommand | SetShadowCommand | TransformMatrixCommand - | SetOpacityCommand; + | SetOpacityCommand + | FillRoundedRectCommand + | StrokeRoundedRectCommand; /** * Bounding box for damage tracking @@ -349,6 +378,8 @@ export class DisplayList { break; case PaintCommandType.DRAW_IMAGE: + case PaintCommandType.FILL_ROUNDED_RECT: + case PaintCommandType.STROKE_ROUNDED_RECT: commandBox = { x: command.x, y: command.y, @@ -520,9 +551,55 @@ export class DisplayList { context.shadowBlur = command.blur; context.shadowColor = command.color; break; + + case PaintCommandType.FILL_ROUNDED_RECT: { + const cmd = command as FillRoundedRectCommand; + context.fillStyle = cmd.color; + this.drawRoundedRect(context, cmd.x, cmd.y, cmd.width, cmd.height, cmd.radii); + context.fill(); + break; + } + + case PaintCommandType.STROKE_ROUNDED_RECT: { + const cmd = command as StrokeRoundedRectCommand; + context.strokeStyle = cmd.color; + context.lineWidth = cmd.lineWidth; + this.drawRoundedRect(context, cmd.x, cmd.y, cmd.width, cmd.height, cmd.radii); + context.stroke(); + break; + } } } + /** + * Draw a rounded rectangle path using arc segments. + */ + private drawRoundedRect( + context: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + radii: [number, number, number, number], + ): void { + const [tl, tr, br, bl] = radii; + context.beginPath(); + context.moveTo(x + tl, y); + context.lineTo(x + w - tr, y); + if (tr > 0) context.arcTo(x + w, y, x + w, y + tr, tr); + else context.lineTo(x + w, y); + context.lineTo(x + w, y + h - br); + if (br > 0) context.arcTo(x + w, y + h, x + w - br, y + h, br); + else context.lineTo(x + w, y + h); + context.lineTo(x + bl, y + h); + if (bl > 0) context.arcTo(x, y + h, x, y + h - bl, bl); + else context.lineTo(x, y + h); + context.lineTo(x, y + tl); + if (tl > 0) context.arcTo(x, y, x + tl, y, tl); + else context.lineTo(x, y); + context.closePath(); + } + /** * Serialize display list to binary format * Used for sending to compositor thread or caching @@ -573,7 +650,7 @@ export class DisplayList { // Simplified - only check rect commands if ("x" in command && "y" in command && "width" in command && "height" in command) { return this.boxesIntersect( - { x: command.x, y: command.y, width: command.width, height: command.height }, + { x: command.x as number, y: command.y as number, width: command.width as number, height: command.height as number }, region, ); } diff --git a/browser/src/engine/rendering/paint/PaintContext.ts b/browser/src/engine/rendering/paint/PaintContext.ts index d20b968..a2e5fd7 100644 --- a/browser/src/engine/rendering/paint/PaintContext.ts +++ b/browser/src/engine/rendering/paint/PaintContext.ts @@ -145,4 +145,39 @@ export class PaintContext { params: { font }, }); } + + /** + * Fill rounded rectangle + */ + fillRoundedRect( + x: Pixels, + y: Pixels, + width: Pixels, + height: Pixels, + color: string, + radii: [number, number, number, number], + ): void { + this.commands.push({ + type: PaintCommandType.FILL_ROUNDED_RECT, + params: { x, y, width, height, color, radii }, + }); + } + + /** + * Stroke rounded rectangle + */ + strokeRoundedRect( + x: Pixels, + y: Pixels, + width: Pixels, + height: Pixels, + color: string, + lineWidth: Pixels, + radii: [number, number, number, number], + ): void { + this.commands.push({ + type: PaintCommandType.STROKE_ROUNDED_RECT, + params: { x, y, width, height, color, lineWidth, radii }, + }); + } } diff --git a/browser/src/engine/rendering/paint/PaintLayer.ts b/browser/src/engine/rendering/paint/PaintLayer.ts index 6cb8a55..2d744b0 100644 --- a/browser/src/engine/rendering/paint/PaintLayer.ts +++ b/browser/src/engine/rendering/paint/PaintLayer.ts @@ -356,6 +356,68 @@ export class PaintLayer { alpha: opacity, }); }, + setShadow: (offsetX: Pixels, offsetY: Pixels, blur: Pixels, color: string) => { + displayList.add({ + type: PaintCommandType.SET_SHADOW, + offsetX, + offsetY, + blur, + color, + }); + }, + clearShadow: () => { + displayList.add({ + type: PaintCommandType.SET_SHADOW, + offsetX: 0, + offsetY: 0, + blur: 0, + color: "transparent", + }); + }, + setFont: (font: string) => { + displayList.add({ + type: PaintCommandType.SET_FONT, + font, + }); + }, + fillRoundedRect: ( + x: Pixels, + y: Pixels, + width: Pixels, + height: Pixels, + color: string, + radii: [number, number, number, number], + ) => { + displayList.add({ + type: PaintCommandType.FILL_ROUNDED_RECT, + x, + y, + width, + height, + color, + radii, + }); + }, + strokeRoundedRect: ( + x: Pixels, + y: Pixels, + width: Pixels, + height: Pixels, + color: string, + lineWidth: Pixels, + radii: [number, number, number, number], + ) => { + displayList.add({ + type: PaintCommandType.STROKE_ROUNDED_RECT, + x, + y, + width, + height, + color, + lineWidth, + radii, + }); + }, }; } diff --git a/browser/src/engine/rendering/paint/RenderToPixels.ts b/browser/src/engine/rendering/paint/RenderToPixels.ts index 805e88c..fbf01cd 100644 --- a/browser/src/engine/rendering/paint/RenderToPixels.ts +++ b/browser/src/engine/rendering/paint/RenderToPixels.ts @@ -163,7 +163,7 @@ export class RenderToPixels { * Create canvas element */ private createCanvas(width: Pixels, height: Pixels): HTMLCanvasElement { - const canvas = document.createElement("canvas"); + const canvas = document.createElement("canvas") as unknown as HTMLCanvasElement; canvas.width = width; canvas.height = height; return canvas; diff --git a/browser/src/engine/rendering/pdf/PDFGenerator.ts b/browser/src/engine/rendering/pdf/PDFGenerator.ts index d273df8..ca31e63 100644 --- a/browser/src/engine/rendering/pdf/PDFGenerator.ts +++ b/browser/src/engine/rendering/pdf/PDFGenerator.ts @@ -134,7 +134,7 @@ ${new TextDecoder("latin1").decode(imageData)} endstream`; } else if (isPNG) { // PNG requires decoding to raw RGB data, then re-compress for FlateDecode - const rawData = await this.decodePNG(imageData); + const rawData = await this.decodePNG(imageData as Uint8Array); const compressed = await this.compressDeflate(rawData); imageContent = `<< /Type /XObject @@ -257,7 +257,7 @@ endstream`; const writer = ds.writable.getWriter(); const reader = ds.readable.getReader(); - const writePromise = writer.write(data).then(() => writer.close()); + const writePromise = writer.write(data as unknown as BufferSource).then(() => writer.close()); const chunks: Uint8Array[] = []; while (true) { @@ -289,7 +289,7 @@ endstream`; const writer = cs.writable.getWriter(); const reader = cs.readable.getReader(); - const writePromise = writer.write(data).then(() => writer.close()); + const writePromise = writer.write(data as unknown as BufferSource).then(() => writer.close()); const chunks: Uint8Array[] = []; while (true) { diff --git a/browser/src/engine/rendering/rendering/RenderBox.ts b/browser/src/engine/rendering/rendering/RenderBox.ts index 1bfcdc4..eb6b830 100644 --- a/browser/src/engine/rendering/rendering/RenderBox.ts +++ b/browser/src/engine/rendering/rendering/RenderBox.ts @@ -173,6 +173,7 @@ export class RenderBox extends RenderObject { } this.needsLayout = false; + // Note: hasDescendantsNeedingLayout is cleared by LayoutEngine after children are laid out this.markNeedsPaint(); } @@ -320,24 +321,69 @@ export class RenderBox extends RenderObject { } /** - * Paint background + * Paint background (color and image) */ protected paintBackground(context: PaintContext): void { if (!this.layout) return; + const paddingBox = this.layout.getPaddingBox(); + const radii = this.getBorderRadii(); + + // Background color const backgroundColor = this.getColorValue("background-color", "transparent"); - if (backgroundColor === "transparent") { - return; + if (backgroundColor !== "transparent") { + if (radii) { + context.fillRoundedRect( + paddingBox.x, + paddingBox.y, + paddingBox.width, + paddingBox.height, + backgroundColor, + radii, + ); + } else { + context.fillRect( + paddingBox.x, + paddingBox.y, + paddingBox.width, + paddingBox.height, + backgroundColor, + ); + } } - const paddingBox = this.layout.getPaddingBox(); - context.fillRect( - paddingBox.x, - paddingBox.y, - paddingBox.width, - paddingBox.height, - backgroundColor, - ); + // Background image + const bgImage = this.style.getPropertyValue("background-image"); + if (bgImage && bgImage !== "none") { + // Extract URL from url("...") or url(...) + const urlMatch = bgImage.match(/url\(["']?([^"')]+)["']?\)/); + if (urlMatch) { + context.drawImage( + urlMatch[1], + paddingBox.x, + paddingBox.y, + paddingBox.width, + paddingBox.height, + ); + } + } + } + + /** + * Parse border-radius into [topLeft, topRight, bottomRight, bottomLeft] or null if none. + */ + protected getBorderRadii(): [number, number, number, number] | null { + const br = this.style.getPropertyValue("border-radius"); + if (!br || br === "0" || br === "0px") return null; + + // Parse shorthand: "10px" or "10px 5px" or "10px 5px 3px 1px" + const parts = br.split(/\s+/).map((p: string) => parseFloat(p) || 0); + if (parts.every((p: number) => p === 0)) return null; + + if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]]; + if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]]; + if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]]; + return [parts[0], parts[1], parts[2], parts[3]]; } /** @@ -348,7 +394,33 @@ export class RenderBox extends RenderObject { const borderBox = this.layout.getBorderBox(); const paddingBox = this.layout.getPaddingBox(); + const radii = this.getBorderRadii(); + + // If border-radius is set AND all borders have the same width and color, + // use a single rounded rect stroke instead of 4 individual fills. + if (radii) { + const uniformWidth = this.layout.borderTopWidth; + const uniformColor = this.getColorValue("border-top-color", "black"); + const allSame = uniformWidth > 0 && + this.layout.borderRightWidth === uniformWidth && + this.layout.borderBottomWidth === uniformWidth && + this.layout.borderLeftWidth === uniformWidth; + + if (allSame) { + context.strokeRoundedRect( + borderBox.x, + borderBox.y, + borderBox.width, + borderBox.height, + uniformColor, + uniformWidth, + radii, + ); + return; + } + } + // Fallback: paint each border side as a rectangle // Top border if (this.layout.borderTopWidth > 0) { const borderColor = this.getColorValue("border-top-color", "black"); diff --git a/browser/src/engine/rendering/rendering/RenderObject.ts b/browser/src/engine/rendering/rendering/RenderObject.ts index f857305..81e38ce 100644 --- a/browser/src/engine/rendering/rendering/RenderObject.ts +++ b/browser/src/engine/rendering/rendering/RenderObject.ts @@ -33,6 +33,7 @@ export abstract class RenderObject { // Layout state layout: LayoutBox | null = null; needsLayout: boolean = true; + hasDescendantsNeedingLayout: boolean = false; // Paint state paintLayer: PaintLayer | null = null; @@ -59,7 +60,14 @@ export abstract class RenderObject { this.needsLayout = true; this.pixelValueCache.clear(); - // Propagate up the tree + // Propagate descendant dirty flag up the tree + let ancestor = this.parent; + while (ancestor && !ancestor.hasDescendantsNeedingLayout) { + ancestor.hasDescendantsNeedingLayout = true; + ancestor = ancestor.parent; + } + + // Propagate needsLayout up the tree if (this.parent) { this.parent.markNeedsLayout(); } diff --git a/browser/src/engine/rendering/rendering/RenderTreeBuilder.ts b/browser/src/engine/rendering/rendering/RenderTreeBuilder.ts index 6caff00..c8434da 100644 --- a/browser/src/engine/rendering/rendering/RenderTreeBuilder.ts +++ b/browser/src/engine/rendering/rendering/RenderTreeBuilder.ts @@ -86,12 +86,218 @@ export class RenderTreeBuilder { return true; } + /** + * User-agent default display values for HTML elements. + * Applied when the author stylesheet doesn't set display explicitly. + */ + private static readonly UA_BLOCK_ELEMENTS = new Set([ + "html", "body", "div", "section", "article", "aside", "nav", "main", + "header", "footer", "h1", "h2", "h3", "h4", "h5", "h6", + "p", "blockquote", "pre", "figure", "figcaption", + "ul", "ol", "dl", "dt", "dd", "form", "fieldset", "legend", + "details", "summary", "dialog", "address", "hr", "center", + "noscript", "template", "hgroup", "search", + ]); + + private static readonly UA_LIST_ITEM_ELEMENTS = new Set(["li"]); + + private static readonly UA_TABLE_ELEMENTS: Record = { + "table": "table", + "thead": "table-header-group", + "tbody": "table-row-group", + "tfoot": "table-footer-group", + "tr": "table-row", + "td": "table-cell", + "th": "table-cell", + "caption": "table-caption", + "colgroup": "table-column-group", + "col": "table-column", + }; + + /** + * User-agent default styles for HTML elements (font-size, margins, etc.). + */ + private static readonly UA_STYLES: Record> = { + "h1": { "font-size": "32px", "font-weight": "bold", "margin-top": "21px", "margin-bottom": "21px" }, + "h2": { "font-size": "24px", "font-weight": "bold", "margin-top": "19px", "margin-bottom": "19px" }, + "h3": { "font-size": "18.7px", "font-weight": "bold", "margin-top": "18px", "margin-bottom": "18px" }, + "h4": { "font-size": "16px", "font-weight": "bold", "margin-top": "21px", "margin-bottom": "21px" }, + "h5": { "font-size": "13.3px", "font-weight": "bold", "margin-top": "22px", "margin-bottom": "22px" }, + "h6": { "font-size": "10.7px", "font-weight": "bold", "margin-top": "25px", "margin-bottom": "25px" }, + "p": { "margin-top": "16px", "margin-bottom": "16px" }, + "blockquote": { "margin-top": "16px", "margin-bottom": "16px", "margin-left": "40px", "margin-right": "40px" }, + "ul": { "margin-top": "16px", "margin-bottom": "16px", "padding-left": "40px" }, + "ol": { "margin-top": "16px", "margin-bottom": "16px", "padding-left": "40px" }, + "li": { "margin-top": "0", "margin-bottom": "0" }, + "pre": { "font-family": "monospace", "white-space": "pre" }, + "code": { "font-family": "monospace" }, + "body": { "margin-top": "8px", "margin-right": "8px", "margin-bottom": "8px", "margin-left": "8px" }, + "hr": { "margin-top": "8px", "margin-bottom": "8px", "border-top-width": "1px", "border-top-style": "solid", "border-top-color": "#ccc" }, + "a": { "color": "#0000ee", "text-decoration": "underline" }, + "b": { "font-weight": "bold" }, + "strong": { "font-weight": "bold" }, + "i": { "font-style": "italic" }, + "em": { "font-style": "italic" }, + "small": { "font-size": "13px" }, + "center": { "text-align": "center" }, + "font": {}, + }; + + /** + * Apply user-agent default styles to a computed style. + * Only sets properties that the author stylesheet hasn't already set. + */ + private applyUADefaults(tagName: string | undefined, style: ComputedStyle): void { + if (!tagName) return; + const tag = tagName.toLowerCase(); + + // Apply UA display defaults if author didn't set display + const authorDisplay = style.getPropertyValue("display"); + if (!authorDisplay || authorDisplay === "inline") { + // Table elements get their specific display values (must check before block) + if (tag in RenderTreeBuilder.UA_TABLE_ELEMENTS) { + style.setProperty("display", RenderTreeBuilder.UA_TABLE_ELEMENTS[tag]); + } else if (RenderTreeBuilder.UA_BLOCK_ELEMENTS.has(tag)) { + style.setProperty("display", "block"); + } else if (RenderTreeBuilder.UA_LIST_ITEM_ELEMENTS.has(tag)) { + style.setProperty("display", "list-item"); + } + } + + // Apply UA styles (font-size, margins, etc.) + const uaStyles = RenderTreeBuilder.UA_STYLES[tag]; + if (uaStyles) { + for (const [prop, val] of Object.entries(uaStyles)) { + const authorVal = style.getPropertyValue(prop); + // Only apply if author didn't explicitly set this property + if (!authorVal || authorVal === "0" || authorVal === "medium" || authorVal === "normal" || authorVal === "serif") { + style.setProperty(prop, val); + } + } + } + } + + /** + * Apply HTML presentational attributes (bgcolor, color, width, align, font tag, etc.) + * These have lower specificity than CSS — only apply if no author style is set. + */ + private applyPresentationalAttributes(element: DOMElement, style: ComputedStyle): void { + if (!element.getAttribute) return; + const tag = element.tagName?.toLowerCase(); + + // bgcolor attribute → background-color + const bgcolor = element.getAttribute("bgcolor"); + if (bgcolor) { + const existing = style.getPropertyValue("background-color"); + if (!existing || existing === "transparent" || existing === "rgba(0, 0, 0, 0)") { + style.setProperty("background-color", bgcolor); + } + } + + // color attribute → color + const color = element.getAttribute("color"); + if (color && !style.getPropertyValue("color")) { + style.setProperty("color", color.startsWith("#") ? color : color); + } + + // width attribute → width (on table, td, th, img, etc.) + const width = element.getAttribute("width"); + if (width) { + const w = width.endsWith("%") ? width : width + "px"; + if (!style.getPropertyValue("width")) style.setProperty("width", w); + } + + // height attribute + const height = element.getAttribute("height"); + if (height) { + const h = height.endsWith("%") ? height : height + "px"; + if (!style.getPropertyValue("height")) style.setProperty("height", h); + } + + // align attribute → text-align + const align = element.getAttribute("align"); + if (align && !style.getPropertyValue("text-align")) { + style.setProperty("text-align", align.toLowerCase()); + } + + // valign attribute → vertical-align + const valign = element.getAttribute("valign"); + if (valign && !style.getPropertyValue("vertical-align")) { + style.setProperty("vertical-align", valign.toLowerCase()); + } + + // cellpadding on table → padding on td/th children (stored as data for layout) + const cellpadding = element.getAttribute("cellpadding"); + if (cellpadding && (tag === "table")) { + style.setProperty("--cellpadding", cellpadding + "px"); + } + + // cellspacing on table → border-spacing + const cellspacing = element.getAttribute("cellspacing"); + if (cellspacing && (tag === "table") && !style.getPropertyValue("border-spacing")) { + style.setProperty("border-spacing", cellspacing + "px"); + } + + // border attribute + const border = element.getAttribute("border"); + if (border && !style.getPropertyValue("border-width")) { + const bw = border === "0" ? "0" : (border + "px"); + style.setProperty("border-width", bw); + if (border !== "0") { + style.setProperty("border-style", "solid"); + style.setProperty("border-color", "#000"); + } + } + + // tag attributes + if (tag === "font") { + const fcolor = element.getAttribute("color"); + if (fcolor) style.setProperty("color", fcolor); + const fsize = element.getAttribute("size"); + if (fsize) { + // HTML font size: 1-7 maps to CSS px values + const sizeMap: Record = { + "1": "10px", "2": "13px", "3": "16px", "4": "18px", + "5": "24px", "6": "32px", "7": "48px", + "-2": "10px", "-1": "13px", "+0": "16px", + "+1": "18px", "+2": "24px", "+3": "32px", "+4": "48px", + }; + style.setProperty("font-size", sizeMap[fsize] || "16px"); + } + const fface = element.getAttribute("face"); + if (fface) style.setProperty("font-family", fface); + } + + // Inherit cellpadding from parent table to td/th + if (tag === "td" || tag === "th") { + // Walk up to find parent table's cellpadding + let parent = element.parentNode; + while (parent) { + const pEl = parent as DOMElement; + if (pEl.tagName?.toLowerCase() === "table") { + const cp = pEl.getAttribute?.("cellpadding"); + if (cp && cp !== "0" && !style.getPropertyValue("padding")) { + style.setProperty("padding", cp + "px"); + } + break; + } + parent = pEl.parentNode; + } + } + } + /** * Create appropriate RenderObject type based on element and style */ private createRenderObject(element: DOMElement, style: ComputedStyle): RenderObject { const tagName = element.tagName?.toLowerCase(); + // Apply user-agent default styles + this.applyUADefaults(tagName, style); + + // Apply HTML presentational attributes + this.applyPresentationalAttributes(element, style); + // Check if replaced element if (this.isReplacedElement(tagName)) { return new RenderReplaced(element, style); diff --git a/browser/src/engine/rendering/text/FontEngine.ts b/browser/src/engine/rendering/text/FontEngine.ts new file mode 100644 index 0000000..fd80414 --- /dev/null +++ b/browser/src/engine/rendering/text/FontEngine.ts @@ -0,0 +1,445 @@ +/** + * FontEngine — TTF/OTF glyph rasterization using fontdue WASM bindings. + * + * Uses `denosaurs/font` (Deno WASM bindings to the fontdue Rust crate) + * for high-quality anti-aliased glyph rendering from TrueType/OpenType fonts. + * + * Font resolution: CSS font-family → loaded fonts → system fonts → bundled fallback + * + * Usage: + * const engine = new FontEngine(); + * await engine.initialize(); // loads WASM module + * await engine.discoverSystemFonts(); // finds system TTF/OTF files + * const glyph = engine.rasterizeGlyph("A", 16, "sans-serif"); + * const width = engine.measureText("Hello world", "sans-serif", 16); + */ + +// deno-lint-ignore-file no-explicit-any + +/** + * Rasterized glyph — alpha coverage bitmap + metrics + */ +export interface RasterizedGlyph { + /** Alpha coverage values (0-255) per pixel, row-major */ + bitmap: Uint8Array; + /** Bitmap width in pixels */ + width: number; + /** Bitmap height in pixels */ + height: number; + /** Pixel offset of left-most edge (may be negative) */ + xmin: number; + /** Pixel offset of bottom-most edge (may be negative = below baseline) */ + ymin: number; + /** Horizontal advance to next character (subpixels) */ + advanceWidth: number; +} + +/** + * Font metrics for a given size + */ +export interface FontMetrics { + ascent: number; + descent: number; + lineGap: number; + lineHeight: number; +} + +/** + * Internal: a loaded font with its fontdue handle + */ +interface LoadedFont { + name: string; + path: string; + handle: any; // fontdue Font instance +} + +/** + * Map generic CSS font-family names to common system font filenames. + */ +const GENERIC_FONT_FAMILIES: Record = { + "sans-serif": [ + "Arial", "Helvetica", "Helvetica Neue", "HelveticaNeue", + "Liberation Sans", "DejaVu Sans", "DejaVuSans", + "Noto Sans", "NotoSans", "Roboto", "SF Pro", "SFPro", + "Segoe UI", "SegoeUI", + ], + "serif": [ + "Times New Roman", "TimesNewRoman", "Times", + "Liberation Serif", "DejaVu Serif", "DejaVuSerif", + "Noto Serif", "NotoSerif", "Georgia", + ], + "monospace": [ + "Courier New", "CourierNew", "Courier", + "Liberation Mono", "DejaVu Sans Mono", "DejaVuSansMono", + "Noto Sans Mono", "NotoSansMono", "Menlo", "Consolas", + "SF Mono", "SFMono", + ], + "system-ui": [ + "SF Pro", "SFPro", "Segoe UI", "SegoeUI", + "Roboto", "Helvetica Neue", "HelveticaNeue", "Arial", + ], +}; + +/** + * Platform-specific system font directories. + */ +function getSystemFontDirs(): string[] { + const os = Deno.build.os; + const home = Deno.env.get("HOME") ?? ""; + if (os === "darwin") { + return [ + "/System/Library/Fonts", + "/System/Library/Fonts/Supplemental", + "/Library/Fonts", + `${home}/Library/Fonts`, + ]; + } + if (os === "linux") { + return [ + "/usr/share/fonts", + "/usr/local/share/fonts", + `${home}/.fonts`, + `${home}/.local/share/fonts`, + ]; + } + const windir = Deno.env.get("WINDIR") ?? "C:\\Windows"; + const localAppData = Deno.env.get("LOCALAPPDATA") ?? ""; + return [ + `${windir}\\Fonts`, + localAppData ? `${localAppData}\\Microsoft\\Windows\\Fonts` : "", + ].filter(Boolean); +} + +/** + * FontEngine provides real TTF/OTF glyph rasterization via fontdue WASM. + */ +export class FontEngine { + /** fontdue Font class constructor */ + private FontClass: any = null; + /** loaded fonts keyed by lowercase name */ + private fonts = new Map(); + /** glyph raster cache: "family:size:char" → RasterizedGlyph */ + private glyphCache = new Map(); + private initialized = false; + private initFailed = false; + + /** + * Load the fontdue WASM module. + * Returns true if successful, false if fontdue is unavailable. + */ + async initialize(): Promise { + if (this.initialized) return !this.initFailed; + this.initialized = true; + + try { + const mod = await import("https://deno.land/x/font@0.1.3/mod.ts"); + this.FontClass = (mod as any).Font ?? (mod as any).default; + if (!this.FontClass) { + this.initFailed = true; + return false; + } + return true; + } catch { + this.initFailed = true; + return false; + } + } + + /** + * Load a font from a file path. + */ + async loadFont(name: string, path: string): Promise { + if (!this.FontClass) return false; + try { + const data = await Deno.readFile(path); + const handle = new this.FontClass(data); + const key = name.toLowerCase(); + this.fonts.set(key, { name, path, handle }); + return true; + } catch { + return false; + } + } + + /** + * Load a font from raw byte data. + */ + loadFontFromBytes(name: string, data: Uint8Array): boolean { + if (!this.FontClass) return false; + try { + const handle = new this.FontClass(data); + this.fonts.set(name.toLowerCase(), { name, path: "", handle }); + return true; + } catch { + return false; + } + } + + /** + * Discover and load system fonts for generic CSS families. + * Scans system font directories and loads the first match for + * each generic family (sans-serif, serif, monospace, system-ui). + * Returns total number of fonts loaded. + */ + async discoverSystemFonts(): Promise { + if (!this.FontClass) return 0; + const dirs = getSystemFontDirs(); + let loaded = 0; + + // Collect all font files first + const fontFiles: Array<{ name: string; path: string }> = []; + for (const dir of dirs) { + try { + for await (const entry of Deno.readDir(dir)) { + if (!entry.isFile) continue; + const lower = entry.name.toLowerCase(); + if (!lower.endsWith(".ttf") && !lower.endsWith(".otf")) continue; + fontFiles.push({ + name: entry.name.replace(/\.(ttf|otf)$/i, ""), + path: `${dir}/${entry.name}`, + }); + } + } catch { + // Directory not accessible + } + } + + // Match fonts to generic families + for (const [generic, candidates] of Object.entries(GENERIC_FONT_FAMILIES)) { + if (this.fonts.has(generic)) continue; // already loaded + for (const candidate of candidates) { + const candidateLower = candidate.toLowerCase().replace(/\s+/g, ""); + const match = fontFiles.find((f) => { + const fLower = f.name.toLowerCase().replace(/[\s-_]+/g, ""); + // Match "Arial" to "Arial.ttf", "arial-regular.ttf", etc. + return fLower === candidateLower || + fLower.startsWith(candidateLower) && ( + fLower === candidateLower || + fLower[candidateLower.length] === "-" || + fLower.includes("regular") + ); + }); + if (match) { + if (await this.loadFont(generic, match.path)) { + // Also register under the specific font name + const specificKey = candidate.toLowerCase(); + if (!this.fonts.has(specificKey)) { + this.fonts.set(specificKey, this.fonts.get(generic)!); + } + loaded++; + break; + } + } + } + } + + return loaded; + } + + /** + * Resolve a CSS font-family string to a loaded font handle. + * Tries each family in the comma-separated stack, then falls back. + */ + resolveFont(fontFamily: string): LoadedFont | null { + const families = fontFamily + .split(",") + .map((f) => f.trim().replace(/['"]/g, "").toLowerCase()); + + for (const family of families) { + const font = this.fonts.get(family); + if (font) return font; + } + + // Fallback chain: sans-serif → serif → first loaded font + for (const fallback of ["sans-serif", "serif", "monospace"]) { + const font = this.fonts.get(fallback); + if (font) return font; + } + + // Last resort: first loaded font + const first = this.fonts.values().next(); + return first.done ? null : first.value; + } + + /** + * Rasterize a single character glyph at the given font size. + * Returns alpha coverage bitmap with positioning metrics. + */ + rasterizeGlyph( + char: string, + fontSize: number, + fontFamily = "sans-serif", + ): RasterizedGlyph | null { + const cacheKey = `${fontFamily}:${fontSize}:${char}`; + const cached = this.glyphCache.get(cacheKey); + if (cached) return cached; + + const font = this.resolveFont(fontFamily); + if (!font) return null; + + try { + const result = font.handle.rasterize(char, fontSize); + if (!result) return null; + + const metrics = result.metrics ?? result; + const bitmap = result.bitmap ?? new Uint8Array(0); + + const glyph: RasterizedGlyph = { + bitmap: bitmap instanceof Uint8Array ? bitmap : new Uint8Array(bitmap), + width: metrics.width ?? 0, + height: metrics.height ?? 0, + xmin: metrics.xmin ?? 0, + ymin: metrics.ymin ?? 0, + advanceWidth: metrics.advance_width ?? metrics.advanceWidth ?? fontSize * 0.55, + }; + + this.glyphCache.set(cacheKey, glyph); + return glyph; + } catch { + return null; + } + } + + /** + * Measure text width using real advance widths. + */ + measureText(text: string, fontFamily: string, fontSize: number): number { + let total = 0; + for (let i = 0; i < text.length; i++) { + const glyph = this.rasterizeGlyph(text[i], fontSize, fontFamily); + if (glyph) { + total += glyph.advanceWidth; + } else { + // Proportional fallback + total += fontSize * 0.55; + } + } + return total; + } + + /** + * Render a full string of text into an RGBA pixel buffer + * using real anti-aliased glyph bitmaps. + * + * @param pixels Target RGBA buffer + * @param bufW Buffer width + * @param bufH Buffer height + * @param text String to render + * @param x Left x position + * @param baselineY Baseline y position + * @param fontSize Font size in pixels + * @param fontFamily CSS font-family string + * @param color Fill color [r, g, b, a] (rgb 0-255, a 0-1) + * @param alpha Global alpha multiplier (0-1) + * @returns number of characters rendered with real glyphs (0 = all missed) + */ + renderText( + pixels: Uint8ClampedArray, + bufW: number, + bufH: number, + text: string, + x: number, + baselineY: number, + fontSize: number, + fontFamily: string, + color: [number, number, number, number], + alpha: number, + ): number { + let curX = x; + let rendered = 0; + + for (let i = 0; i < text.length; i++) { + const glyph = this.rasterizeGlyph(text[i], fontSize, fontFamily); + if (!glyph || glyph.width === 0 || glyph.height === 0) { + // No glyph — advance by estimated width, caller can overlay bitmap fallback + curX += glyph?.advanceWidth ?? fontSize * 0.55; + continue; + } + + rendered++; + + // glyph.xmin = offset from pen to left edge of bitmap + // glyph.ymin = offset from bottom of bitmap to baseline + // (positive = above baseline, but fontdue uses bottom-up ymin) + // For screen coords (y-down): glyph top = baselineY - (ymin + height) + const gx = Math.round(curX + glyph.xmin); + const gy = Math.round(baselineY - glyph.ymin - glyph.height); + + for (let row = 0; row < glyph.height; row++) { + const py = gy + row; + if (py < 0 || py >= bufH) continue; + for (let col = 0; col < glyph.width; col++) { + const px = gx + col; + if (px < 0 || px >= bufW) continue; + + const coverage = glyph.bitmap[row * glyph.width + col]; + if (coverage === 0) continue; + + const srcA = (coverage / 255) * color[3] * alpha; + const idx = (py * bufW + px) * 4; + + if (srcA >= 0.999) { + // Opaque fast path + pixels[idx] = color[0]; + pixels[idx + 1] = color[1]; + pixels[idx + 2] = color[2]; + pixels[idx + 3] = 255; + } else { + const dstA = pixels[idx + 3] / 255; + const outA = srcA + dstA * (1 - srcA); + if (outA > 0) { + pixels[idx] = Math.round( + (color[0] * srcA + pixels[idx] * dstA * (1 - srcA)) / outA, + ); + pixels[idx + 1] = Math.round( + (color[1] * srcA + pixels[idx + 1] * dstA * (1 - srcA)) / outA, + ); + pixels[idx + 2] = Math.round( + (color[2] * srcA + pixels[idx + 2] * dstA * (1 - srcA)) / outA, + ); + pixels[idx + 3] = Math.round(outA * 255); + } + } + } + } + + curX += glyph.advanceWidth; + } + + return rendered; + } + + /** Check if real rasterization is available */ + isAvailable(): boolean { + return this.FontClass !== null; + } + + /** Check if any fonts are loaded */ + hasFonts(): boolean { + return this.fonts.size > 0; + } + + /** Get number of loaded fonts */ + getFontCount(): number { + return this.fonts.size; + } + + /** Get loaded font family names */ + getFontNames(): string[] { + return Array.from(this.fonts.keys()); + } + + /** Clear glyph raster cache */ + clearCache(): void { + this.glyphCache.clear(); + } + + /** Dispose all fonts and caches */ + dispose(): void { + this.fonts.clear(); + this.glyphCache.clear(); + this.FontClass = null; + this.initialized = false; + this.initFailed = false; + } +} diff --git a/browser/src/engine/webgpu/compositor/WebGPUCompositorLayer.ts b/browser/src/engine/webgpu/compositor/WebGPUCompositorLayer.ts index b13e0e0..9c569e2 100644 --- a/browser/src/engine/webgpu/compositor/WebGPUCompositorLayer.ts +++ b/browser/src/engine/webgpu/compositor/WebGPUCompositorLayer.ts @@ -554,7 +554,7 @@ export class WebGPUCompositorLayer { } // Create canvas for rasterization - const canvas = document.createElement("canvas"); + const canvas = document.createElement("canvas") as unknown as { width: number; height: number; getContext(id: string): any }; canvas.width = this.config.width; canvas.height = this.config.height; @@ -573,7 +573,7 @@ export class WebGPUCompositorLayer { const bitmap = await createImageBitmap(canvas as unknown as ImageBitmapSource); // Get pixel data from bitmap via canvas - const tempCanvas = document.createElement("canvas"); + const tempCanvas = document.createElement("canvas") as unknown as { width: number; height: number; getContext(id: string): any }; tempCanvas.width = bitmap.width; tempCanvas.height = bitmap.height; const tempContext = tempCanvas.getContext("2d"); @@ -615,7 +615,7 @@ export class WebGPUCompositorLayer { } // Create canvas for this tile - const canvas = document.createElement("canvas"); + const canvas = document.createElement("canvas") as unknown as { width: number; height: number; getContext(id: string): any }; canvas.width = tile.bounds.width; canvas.height = tile.bounds.height; @@ -640,7 +640,7 @@ export class WebGPUCompositorLayer { const bitmap = await createImageBitmap(canvas as unknown as ImageBitmapSource); // Extract pixel data - const tempCanvas = document.createElement("canvas"); + const tempCanvas = document.createElement("canvas") as unknown as { width: number; height: number; getContext(id: string): any }; tempCanvas.width = bitmap.width; tempCanvas.height = bitmap.height; const tempContext = tempCanvas.getContext("2d"); diff --git a/browser/src/engine/webgpu/operations/compute/ComputePipeline.ts b/browser/src/engine/webgpu/operations/compute/ComputePipeline.ts index dd25307..762ddb0 100644 --- a/browser/src/engine/webgpu/operations/compute/ComputePipeline.ts +++ b/browser/src/engine/webgpu/operations/compute/ComputePipeline.ts @@ -695,9 +695,10 @@ export class ComputePipeline { ); } - // Execute compute pass + // Execute compute pass — unwrap PipelineResult to native pipeline + const nativePipeline = pipeline.nativePipeline ?? pipeline as unknown as GPUComputePipeline; await this.executeComputePass(commandEncoder, { - pipeline, + pipeline: nativePipeline, bindGroups, dispatchWorkgroups: dispatch, label: config.label, diff --git a/browser/src/main.ts b/browser/src/main.ts index 2889cc1..9243bde 100644 --- a/browser/src/main.ts +++ b/browser/src/main.ts @@ -8,6 +8,7 @@ import type { DOMNode } from "./types/dom.ts"; import { RequestPipeline } from "./engine/RequestPipeline.ts"; import { RenderingPipeline } from "./engine/RenderingPipeline.ts"; +import { WindowRenderer } from "./engine/rendering/WindowRenderer.ts"; import { StorageManager } from "./engine/storage/StorageManager.ts"; import { CookieManager } from "./engine/storage/CookieManager.ts"; import { QuotaManager } from "./engine/storage/QuotaManager.ts"; @@ -514,18 +515,36 @@ export async function main(): Promise { console.log("BrowserX - Starting"); console.log("=".repeat(60)); + const width = 1024; + const height = 768; + // Create browser instance const browser = new Browser({ - width: 1024, - height: 768, + width, + height, enableJavaScript: false, enableStorage: true, }); // Load default page or command-line argument const url = Deno.args[0] || "about:blank"; + const headless = Deno.args.includes("--headless"); if (url !== "about:blank") { + // Create and initialize WindowRenderer + const windowRenderer = new WindowRenderer({ + mode: headless ? "offscreen" : "native", + title: `BrowserX — ${url}`, + width, + height, + resizable: true, + vsync: true, + }); + await windowRenderer.initialize(); + + // Wire to rendering pipeline's orchestrator + browser.getRenderingPipeline().setWindowRenderer(windowRenderer); + try { await browser.navigate(url); @@ -538,19 +557,49 @@ export async function main(): Promise { } catch (error) { console.error("Failed to load page:", error); } - } - console.log("\n" + "=".repeat(60)); - console.log("Browser ready. Use browser.navigate(url) to load pages."); - console.log("=".repeat(60)); + // Event loop — keep window open and responsive + if (!headless && windowRenderer.isRunning()) { + console.log("\nWindow open. Close window or press Ctrl+C to exit."); + let running = true; + + while (running) { + const events = await windowRenderer.pollEvents(); + for (const event of events) { + switch (event.type) { + case "close": + running = false; + break; + case "resize": { + const resizeData = event.data as { width?: number; height?: number } | undefined; + if (resizeData?.width && resizeData?.height) { + browser.setViewportSize(resizeData.width, resizeData.height); + windowRenderer.resize(resizeData.width, resizeData.height); + // Re-render at new size + try { + await browser.reload(); + } catch (_e) { + // Ignore re-render errors during resize + } + } + break; + } + } + } + // ~60fps poll rate + await new Promise((resolve) => setTimeout(resolve, 16)); + } - // Keep browser running in REPL mode if no URL provided - if (url === "about:blank") { + windowRenderer.destroy(); + } + + await browser.close(); + } else { + console.log("\n" + "=".repeat(60)); + console.log("Browser ready. Use browser.navigate(url) to load pages."); + console.log("=".repeat(60)); console.log("\nREPL mode - browser instance available as 'browser'"); (globalThis as unknown as { browser: Browser }).browser = browser; - } else { - // Close after loading if URL was provided - await browser.close(); } } diff --git a/browser/src/os/devices/Printer.ts b/browser/src/os/devices/Printer.ts index 2170190..a9c6a80 100644 --- a/browser/src/os/devices/Printer.ts +++ b/browser/src/os/devices/Printer.ts @@ -99,7 +99,8 @@ export class Printer { if (opened) { const mod = await loadSerialx(); if (mod && this.serialDevice.rawPort) { - this.escpos = new mod.EscPos(this.serialDevice.rawPort); + // deno-lint-ignore no-explicit-any + this.escpos = new mod.EscPos(this.serialDevice.rawPort as any); this.escpos.init(); } this.printerName = port; diff --git a/browser/src/os/window/Window.ts b/browser/src/os/window/Window.ts index 5db7c9b..3af9909 100644 --- a/browser/src/os/window/Window.ts +++ b/browser/src/os/window/Window.ts @@ -29,10 +29,43 @@ export interface WindowEvent { data?: unknown; } +/** + * Pixpane FFI bindings interface. + * Matches the exported functions from crates/pixpane/bindings/bindings.ts. + */ +interface PixpaneBindings { + create_window: (config: { title: string; width: number; height: number; resizable?: boolean; visible?: boolean }) => bigint; + window_close: (windowId: bigint) => number; + window_set_title: (windowId: bigint, title: string) => number; + window_set_size: (windowId: bigint, width: number, height: number) => number; + window_upload_pixels: (windowId: bigint, pixels: Uint8Array, width: number, height: number) => number; + window_render: (windowId: bigint) => number; + poll_event: () => Promise<{ has_event: number; event?: { type: string; window_id?: number; data?: unknown } }>; + pump_events: () => void; + get_last_error: () => string; +} + +/** Try to load pixpane bindings. Returns null if unavailable. */ +async function loadPixpane(): Promise { + try { + // Dynamic import — the bindings file opens the dylib on load. + // The path is relative to the browser package root. + const mod = await import("../../../../../crates/pixpane/bindings/bindings.ts"); + if (typeof mod.create_window === "function") { + return mod as unknown as PixpaneBindings; + } + return null; + } catch { + return null; + } +} + export class Window { private config: WindowConfig; private _isOpen: boolean = false; private _isHeadless: boolean = true; + private _windowId: bigint = 0n; + private pixpane: PixpaneBindings | null = null; constructor(config: WindowConfig) { this.config = { ...config }; @@ -40,22 +73,39 @@ export class Window { /** * Open the window. - * Checks for pixpane FFI availability; if unavailable, opens in headless mode. + * Loads pixpane FFI and creates a native window; if unavailable, opens in headless mode. */ async open(): Promise { if (this._isOpen) { return; } - // Check for pixpane FFI availability - try { - const g = globalThis as unknown as Record | undefined>; - if (g.pixpane && typeof g.pixpane.create_window === "function") { + // Attempt to load pixpane FFI + this.pixpane = await loadPixpane(); + + if (this.pixpane) { + try { + const windowId = this.pixpane.create_window({ + title: this.config.title, + width: this.config.width, + height: this.config.height, + resizable: this.config.resizable ?? true, + visible: this.config.visible ?? true, + }); + + if (windowId === 0n) { + const err = this.pixpane.get_last_error(); + throw new Error(`pixpane create_window failed: ${err}`); + } + + this._windowId = windowId; this._isHeadless = false; - } else { + } catch { + // FFI call failed — fall back to headless this._isHeadless = true; + this.pixpane = null; } - } catch { + } else { this._isHeadless = true; } @@ -66,7 +116,12 @@ export class Window { * Close the window and release resources. */ close(): void { + if (this.pixpane && this._windowId !== 0n) { + this.pixpane.window_close(this._windowId); + this._windowId = 0n; + } this._isOpen = false; + this.pixpane = null; } /** @@ -76,6 +131,20 @@ export class Window { return this._isOpen; } + /** + * Get the native pixpane window ID. Returns 0n in headless mode. + */ + getWindowId(): bigint { + return this._windowId; + } + + /** + * Get the pixpane FFI bindings (null in headless mode). + */ + getPixpane(): PixpaneBindings | null { + return this.pixpane; + } + /** * Get the current window dimensions. */ @@ -95,6 +164,9 @@ export class Window { */ setTitle(title: string): void { this.config.title = title; + if (this.pixpane && this._windowId !== 0n) { + this.pixpane.window_set_title(this._windowId, title); + } } /** @@ -103,6 +175,40 @@ export class Window { resize(width: number, height: number): void { this.config.width = width; this.config.height = height; + if (this.pixpane && this._windowId !== 0n) { + this.pixpane.window_set_size(this._windowId, width, height); + } + } + + /** + * Poll for pending window events. Returns empty array in headless mode. + */ + async pollEvents(): Promise { + if (!this.pixpane || this._isHeadless) { + return []; + } + + const events: WindowEvent[] = []; + + // Pump the event loop first + this.pixpane.pump_events(); + + // Drain all pending events + while (true) { + const result = await this.pixpane.poll_event(); + if (!result || result.has_event === 0) break; + + const raw = result.event; + if (raw) { + events.push({ + type: this.mapEventType(raw.type), + windowId: BigInt(raw.window_id ?? 0), + data: raw.data, + }); + } + } + + return events; } /** @@ -111,4 +217,17 @@ export class Window { isHeadlessMode(): boolean { return this._isHeadless; } + + private mapEventType(raw: string): WindowEvent["type"] { + const map: Record = { + "CloseRequested": "close", + "Resized": "resize", + "Focused": "focus", + "Unfocused": "blur", + "KeyboardInput": "keydown", + "CursorMoved": "mousemove", + "MouseInput": "mousedown", + }; + return map[raw] ?? "focus"; + } } diff --git a/browser/src/os/window/WindowContext.ts b/browser/src/os/window/WindowContext.ts index 80a79ae..bdd7661 100644 --- a/browser/src/os/window/WindowContext.ts +++ b/browser/src/os/window/WindowContext.ts @@ -2,7 +2,10 @@ * WindowContext - Rendering context for a Window * * Manages the GPU/rendering context associated with a Window instance. - * Supports vsync and MSAA configuration. No-ops in headless mode. + * Supports vsync and MSAA configuration. + * + * In native mode, present() uploads RGBA8 pixels to the pixpane window and renders. + * In headless mode, present() is a no-op (pixels are stored in WindowRenderer's offscreen buffer). */ import { Window } from "./Window.ts"; @@ -16,6 +19,7 @@ export class WindowContext { private window: Window; private config: WindowContextConfig; private _active: boolean = false; + private frameCount = 0; constructor(window: Window, config?: WindowContextConfig) { this.window = window; @@ -67,13 +71,53 @@ export class WindowContext { } /** - * Present the current frame to the window surface. - * No-op in headless mode. + * Get the number of frames presented. */ - present(): void { + getFrameCount(): number { + return this.frameCount; + } + + /** + * Present pixels to the window surface. + * + * Accepts an RGBA8 pixel buffer and uploads it to the native pixpane window + * via `window_upload_pixels` + `window_render`. No-op in headless mode. + * + * @param pixels RGBA8 pixel data (width * height * 4 bytes) + * @param width Pixel buffer width + * @param height Pixel buffer height + */ + present(pixels?: Uint8ClampedArray, width?: number, height?: number): void { if (this.window.isHeadlessMode()) { + this.frameCount++; + return; + } + + const pixpane = this.window.getPixpane(); + const windowId = this.window.getWindowId(); + + if (!pixpane || windowId === 0n) { + this.frameCount++; return; } - // In non-headless mode, pixpane FFI would swap buffers here + + // Upload pixels if provided + if (pixels && width !== undefined && height !== undefined) { + const u8 = new Uint8Array(pixels.buffer, pixels.byteOffset, pixels.byteLength); + const result = pixpane.window_upload_pixels(windowId, u8, width, height); + if (result !== 0) { + const err = pixpane.get_last_error(); + throw new Error(`window_upload_pixels failed: ${err}`); + } + } + + // Render frame + const renderResult = pixpane.window_render(windowId); + if (renderResult !== 0) { + const err = pixpane.get_last_error(); + throw new Error(`window_render failed: ${err}`); + } + + this.frameCount++; } } diff --git a/browser/src/types/css.ts b/browser/src/types/css.ts index f3366b4..8306b71 100644 --- a/browser/src/types/css.ts +++ b/browser/src/types/css.ts @@ -43,10 +43,16 @@ export interface CSSRule { /** * CSS stylesheet */ +export interface CSSMediaRule { + condition: string; + rules: CSSRule[]; +} + export interface CSSStyleSheet { href: string | null; ownerNode: DOMElement | null; rules: CSSRule[]; + mediaRules: CSSMediaRule[]; disabled: boolean; /** diff --git a/browser/src/types/dom.ts b/browser/src/types/dom.ts index e1d922c..8c5e0eb 100644 --- a/browser/src/types/dom.ts +++ b/browser/src/types/dom.ts @@ -360,6 +360,10 @@ export interface CanvasRenderingContext2D { closePath(): void; moveTo(x: number, y: number): void; lineTo(x: number, y: number): void; + arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, counterclockwise?: boolean): void; + arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void; + quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void; + bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void; stroke(): void; fill(): void; } @@ -2225,10 +2229,10 @@ function createElementFn(tagName: string): DOMElement | HTMLCanvasElement { const sw = seg.w ?? 0; const sh = seg.h ?? 0; // Transform all 4 corners for correct bounding box under rotation - const [tx0, ty0] = transformPoint(seg.x, seg.y); - const [tx1, ty1] = transformPoint(seg.x + sw, seg.y); - const [tx2, ty2] = transformPoint(seg.x + sw, seg.y + sh); - const [tx3, ty3] = transformPoint(seg.x, seg.y + sh); + const [tx0, ty0] = transformPoint(seg.x!, seg.y!); + const [tx1, ty1] = transformPoint(seg.x! + sw, seg.y!); + const [tx2, ty2] = transformPoint(seg.x! + sw, seg.y! + sh); + const [tx3, ty3] = transformPoint(seg.x!, seg.y! + sh); const newClip = { x: Math.min(tx0, tx1, tx2, tx3), y: Math.min(ty0, ty1, ty2, ty3), diff --git a/browser/src/types/rendering.ts b/browser/src/types/rendering.ts index 87083ba..8455b78 100644 --- a/browser/src/types/rendering.ts +++ b/browser/src/types/rendering.ts @@ -103,6 +103,7 @@ export interface RenderObject { // Layout layout: LayoutBox | null; needsLayout: boolean; + hasDescendantsNeedingLayout: boolean; // Paint paintLayer: PaintLayer | null; @@ -154,6 +155,8 @@ export enum PaintCommandType { SET_SHADOW = "setShadow", TRANSFORM = "transform", SET_OPACITY = "setOpacity", + FILL_ROUNDED_RECT = "fillRoundedRect", + STROKE_ROUNDED_RECT = "strokeRoundedRect", } /** @@ -219,6 +222,46 @@ export interface PaintContext { * Set opacity */ setOpacity(opacity: number): void; + + /** + * Set shadow + */ + setShadow(offsetX: Pixels, offsetY: Pixels, blur: Pixels, color: string): void; + + /** + * Clear shadow + */ + clearShadow(): void; + + /** + * Set font + */ + setFont(font: string): void; + + /** + * Fill rounded rectangle + */ + fillRoundedRect( + x: Pixels, + y: Pixels, + width: Pixels, + height: Pixels, + color: string, + radii: [number, number, number, number], + ): void; + + /** + * Stroke rounded rectangle + */ + strokeRoundedRect( + x: Pixels, + y: Pixels, + width: Pixels, + height: Pixels, + color: string, + lineWidth: Pixels, + radii: [number, number, number, number], + ): void; } /** diff --git a/browser/test-render.ts b/browser/test-render.ts new file mode 100644 index 0000000..4dfb79c --- /dev/null +++ b/browser/test-render.ts @@ -0,0 +1,58 @@ +import { RenderingPipeline } from "./src/engine/RenderingPipeline.ts"; + +const url = Deno.args[0] || "https://news.ycombinator.com"; +const html = await (await fetch(url, { headers: { "User-Agent": "Mozilla/5.0" } })).text(); +console.log(`Fetched ${html.length} bytes from ${url}`); + +const ac = new AbortController(); +const server = Deno.serve( + { port: 9940, hostname: "127.0.0.1", signal: ac.signal, onListen: () => {} }, + () => new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8", "Connection": "close" } }), +); + +const pipeline = new RenderingPipeline({ width: 1024, height: 768 }); + +try { + const result = await pipeline.render("http://127.0.0.1:9940"); + + const root = result.renderTree?.getRoot?.() ?? result.renderTree; + let nodeCount = 0, textNodes = 0; + const texts: string[] = []; + function walk(node: any) { + nodeCount++; + if (node.textContent) { textNodes++; if (texts.length < 15) texts.push(node.textContent.trim().slice(0,60)); } + for (const c of (node.children || [])) walk(c); + } + walk(root); + + console.log(`Render tree: ${nodeCount} nodes, ${textNodes} text nodes`); + console.log(`Display list: ${result.displayList.getCommands().length} commands`); + console.log(`\nSample text:`); + for (const t of texts) console.log(` "${t}"`); + + const pixels = await pipeline.getPixels(); + let nw = 0; + for (let i = 0; i < pixels.length; i += 4) if (pixels[i] < 250 || pixels[i+1] < 250 || pixels[i+2] < 250) nw++; + console.log(`\nPixels: ${nw}/${1024*768} non-white (${(nw/(1024*768)*100).toFixed(1)}%)`); + + // Write raw PPM (no external deps) + const w = 1024, h = 768; + const header = `P6\n${w} ${h}\n255\n`; + const headerBytes = new TextEncoder().encode(header); + const rgb = new Uint8Array(w * h * 3); + for (let i = 0, j = 0; i < pixels.length; i += 4, j += 3) { + rgb[j] = pixels[i]; rgb[j+1] = pixels[i+1]; rgb[j+2] = pixels[i+2]; + } + const ppm = new Uint8Array(headerBytes.length + rgb.length); + ppm.set(headerBytes); ppm.set(rgb, headerBytes.length); + const safeName = new URL(url).hostname.replace(/\./g, "_"); + const outPath = `/tmp/browserx-${safeName}.ppm`; + await Deno.writeFile(outPath, ppm); + console.log(`Screenshot: ${outPath}`); +} catch (e) { + console.error("Error:", e); +} finally { + await pipeline.close(); + ac.abort(); + await server.finished; +} diff --git a/browser/tests/engine/javascript/phase2-es6-features.test.ts b/browser/tests/engine/javascript/phase2-es6-features.test.ts new file mode 100644 index 0000000..7805e8c --- /dev/null +++ b/browser/tests/engine/javascript/phase2-es6-features.test.ts @@ -0,0 +1,260 @@ +// @ts-nocheck - JSValue discriminated union requires casting for .value access in tests +import { assertEquals, assertThrows } from "@std/assert"; +import { V8Compiler } from "../../../../browser/src/engine/javascript/V8Compiler.ts"; +import { IgnitionInterpreter } from "../../../../browser/src/engine/javascript/IgnitionInterpreter.ts"; +import { createUndefined, JSValueType } from "../../../../browser/src/engine/javascript/JSValue.ts"; +import { + CallStack, + createExecutionContext, + createGlobalEnvironmentRecord, + createRealm, +} from "../../../../browser/src/engine/javascript/ExecutionContext.ts"; + +function compileAndRun(source: string) { + const compiler = new V8Compiler(); + const compiled = compiler.compile(source); + const interpreter = new IgnitionInterpreter(); + return interpreter.execute(compiled.bytecode, compiled.constantPool); +} + +// ============================================================================ +// TRY/CATCH/THROW tests +// ============================================================================ + +Deno.test("try/catch - catches thrown string", () => { + const result = compileAndRun(` + var result = 0; + try { + throw "error"; + } catch (e) { + result = 1; + } + result + `); + assertEquals(result.type, JSValueType.NUMBER); + assertEquals(result.value, 1); +}); + +Deno.test("try/catch - catch parameter receives thrown value", () => { + const result = compileAndRun(` + var msg = ""; + try { + throw "hello"; + } catch (e) { + msg = e; + } + msg + `); + assertEquals(result.type, JSValueType.STRING); + assertEquals(result.value, "hello"); +}); + +Deno.test("try/catch - finally block executes", () => { + const result = compileAndRun(` + var x = 0; + try { + x = 1; + } finally { + x = 2; + } + x + `); + assertEquals(result.type, JSValueType.NUMBER); + assertEquals(result.value, 2); +}); + +Deno.test("try/catch - finally runs after catch", () => { + const result = compileAndRun(` + var x = 0; + try { + throw "err"; + } catch (e) { + x = 1; + } finally { + x = x + 10; + } + x + `); + assertEquals(result.type, JSValueType.NUMBER); + assertEquals(result.value, 11); +}); + +Deno.test("try/catch - no exception skips catch", () => { + const result = compileAndRun(` + var x = 0; + try { + x = 5; + } catch (e) { + x = 99; + } + x + `); + assertEquals(result.type, JSValueType.NUMBER); + assertEquals(result.value, 5); +}); + +// ============================================================================ +// TYPEOF tests +// ============================================================================ + +Deno.test("typeof - number", () => { + const result = compileAndRun("typeof 42"); + assertEquals(result.type, JSValueType.STRING); + assertEquals(result.value, "number"); +}); + +Deno.test("typeof - string", () => { + const result = compileAndRun('typeof "hello"'); + assertEquals(result.type, JSValueType.STRING); + assertEquals(result.value, "string"); +}); + +Deno.test("typeof - boolean", () => { + const result = compileAndRun("typeof true"); + assertEquals(result.type, JSValueType.STRING); + assertEquals(result.value, "boolean"); +}); + +Deno.test("typeof - undefined", () => { + const result = compileAndRun("typeof undefined"); + assertEquals(result.type, JSValueType.STRING); + assertEquals(result.value, "undefined"); +}); + +Deno.test("typeof - null is object", () => { + const result = compileAndRun("typeof null"); + assertEquals(result.type, JSValueType.STRING); + assertEquals(result.value, "object"); +}); + +Deno.test("typeof - object", () => { + const result = compileAndRun("typeof {}"); + assertEquals(result.type, JSValueType.STRING); + assertEquals(result.value, "object"); +}); + +Deno.test("typeof - function", () => { + const result = compileAndRun("typeof function() {}"); + assertEquals(result.type, JSValueType.STRING); + assertEquals(result.value, "function"); +}); + +// ============================================================================ +// SWITCH/CASE tests +// ============================================================================ + +Deno.test("switch - matches case", () => { + const result = compileAndRun(` + var x = 2; + var result = 0; + switch (x) { + case 1: + result = 10; + break; + case 2: + result = 20; + break; + case 3: + result = 30; + break; + } + result + `); + assertEquals(result.type, JSValueType.NUMBER); + assertEquals(result.value, 20); +}); + +Deno.test("switch - default case", () => { + const result = compileAndRun(` + var x = 99; + var result = 0; + switch (x) { + case 1: + result = 10; + break; + default: + result = -1; + break; + } + result + `); + assertEquals(result.type, JSValueType.NUMBER); + assertEquals(result.value, -1); +}); + +// ============================================================================ +// DO...WHILE tests +// ============================================================================ + +Deno.test("do-while - executes at least once", () => { + const result = compileAndRun(` + var x = 0; + do { + x = x + 1; + } while (false); + x + `); + assertEquals(result.type, JSValueType.NUMBER); + assertEquals(result.value, 1); +}); + +Deno.test("do-while - loops correctly", () => { + const result = compileAndRun(` + var x = 0; + do { + x = x + 1; + } while (x < 5); + x + `); + assertEquals(result.type, JSValueType.NUMBER); + assertEquals(result.value, 5); +}); + +// ============================================================================ +// CLASS tests +// ============================================================================ + +Deno.test("class - basic class declaration", () => { + const result = compileAndRun(` + class Foo { + constructor() { + this.x = 42; + } + } + var f = new Foo(); + f.x + `); + assertEquals(result.type, JSValueType.NUMBER); + assertEquals(result.value, 42); +}); + +Deno.test("class - method on prototype returns this property", () => { + const result = compileAndRun(` + class Greeter { + constructor() { + this.name = "world"; + } + greet() { + return this.name; + } + } + var g = new Greeter(); + g.greet() + `); + // Method should access this.name via the receiver binding + assertEquals(result.type, JSValueType.STRING); + assertEquals(result.value, "world"); +}); + +Deno.test("class - static method", () => { + const result = compileAndRun(` + class MathHelper { + static double(x) { + return x * 2; + } + } + MathHelper.double(21) + `); + assertEquals(result.type, JSValueType.NUMBER); + assertEquals(result.value, 42); +}); diff --git a/browser/tests/engine/rendering/css-parser/media_cascade.test.ts b/browser/tests/engine/rendering/css-parser/media_cascade.test.ts new file mode 100644 index 0000000..cb7eee1 --- /dev/null +++ b/browser/tests/engine/rendering/css-parser/media_cascade.test.ts @@ -0,0 +1,137 @@ +import { assertEquals } from "@std/assert"; +import { CSSTokenizer } from "../../../../src/engine/rendering/css-parser/CSSTokenizer.ts"; +import { CSSParser } from "../../../../src/engine/rendering/css-parser/CSSParser.ts"; +import { CSSOM, StyleSheetOrigin } from "../../../../src/engine/rendering/css-parser/CSSOM.ts"; +import { StyleResolver } from "../../../../src/engine/rendering/css-parser/StyleResolver.ts"; +import type { DOMElement } from "../../../../src/types/dom.ts"; + +function parseSheet(css: string) { + const tokenizer = new CSSTokenizer(); + const tokens = tokenizer.tokenize(css); + const parser = new CSSParser(); + return parser.parse(tokens); +} + +function makeElement(tag: string, id?: string, classes?: string[]): DOMElement { + const attributes = new Map(); + if (id) attributes.set("id", id); + if (classes) attributes.set("class", classes.join(" ")); + return { + nodeType: 1, + nodeName: tag.toUpperCase(), + tagName: tag.toUpperCase(), + attributes, + children: [], + parentElement: null, + } as unknown as DOMElement; +} + +Deno.test("@media rules included in cascade when viewport matches", () => { + const sheet = parseSheet(` + body { color: black; } + @media (min-width: 768px) { + body { color: blue; } + } + `); + + const cssom = new CSSOM(); + cssom.setViewport(1024, 768); + cssom.addStyleSheet(sheet, StyleSheetOrigin.AUTHOR); + + const resolver = new StyleResolver(cssom); + const element = makeElement("body"); + const style = resolver.resolve(element); + + // @media matches at 1024px width, so blue should win (later in source order) + assertEquals(style.getPropertyValue("color"), "blue"); +}); + +Deno.test("@media rules excluded from cascade when viewport doesn't match", () => { + const sheet = parseSheet(` + body { color: black; } + @media (min-width: 768px) { + body { color: blue; } + } + `); + + const cssom = new CSSOM(); + cssom.setViewport(320, 480); + cssom.addStyleSheet(sheet, StyleSheetOrigin.AUTHOR); + + const resolver = new StyleResolver(cssom); + const element = makeElement("body"); + const style = resolver.resolve(element); + + assertEquals(style.getPropertyValue("color"), "black"); +}); + +Deno.test("@media max-width excludes styles above breakpoint", () => { + const sheet = parseSheet(` + body { font-size: 16px; } + @media (max-width: 600px) { + body { font-size: 14px; } + } + `); + + const cssom = new CSSOM(); + cssom.setViewport(800, 600); + cssom.addStyleSheet(sheet, StyleSheetOrigin.AUTHOR); + + const resolver = new StyleResolver(cssom); + const element = makeElement("body"); + const style = resolver.resolve(element); + + assertEquals(style.getPropertyValue("font-size"), "16px"); +}); + +Deno.test("@media max-width includes styles below breakpoint", () => { + const sheet = parseSheet(` + body { font-size: 16px; } + @media (max-width: 600px) { + body { font-size: 14px; } + } + `); + + const cssom = new CSSOM(); + cssom.setViewport(480, 320); + cssom.addStyleSheet(sheet, StyleSheetOrigin.AUTHOR); + + const resolver = new StyleResolver(cssom); + const element = makeElement("body"); + const style = resolver.resolve(element); + + assertEquals(style.getPropertyValue("font-size"), "14px"); +}); + +Deno.test("@media rules with class selectors work", () => { + const sheet = parseSheet(` + .card { background-color: white; } + @media (min-width: 1024px) { + .card { background-color: lightgray; } + } + `); + + const cssom = new CSSOM(); + cssom.setViewport(1280, 720); + cssom.addStyleSheet(sheet, StyleSheetOrigin.AUTHOR); + + const resolver = new StyleResolver(cssom); + const element = makeElement("div", undefined, ["card"]); + const style = resolver.resolve(element); + + assertEquals(style.getPropertyValue("background-color"), "lightgray"); +}); + +Deno.test("mediaRules stored on stylesheet", () => { + const sheet = parseSheet(` + body { margin: 0; } + @media screen and (min-width: 768px) { + body { padding: 20px; } + } + `); + + assertEquals(sheet.mediaRules.length, 1); + // Parser preserves whitespace inside condition — just check it contains the key parts + assertEquals(sheet.mediaRules[0].condition.includes("min-width"), true); + assertEquals(sheet.mediaRules[0].rules.length, 1); +}); diff --git a/browser/tests/engine/rendering/window_renderer.test.ts b/browser/tests/engine/rendering/window_renderer.test.ts new file mode 100644 index 0000000..58dbc24 --- /dev/null +++ b/browser/tests/engine/rendering/window_renderer.test.ts @@ -0,0 +1,118 @@ +import { assertEquals } from "@std/assert"; +import { WindowRenderer } from "../../../src/engine/rendering/WindowRenderer.ts"; + +// --------------------------------------------------------------------------- +// Offscreen mode tests (no pixpane needed) +// --------------------------------------------------------------------------- + +Deno.test("WindowRenderer - offscreen mode initialize/destroy", async () => { + const renderer = new WindowRenderer({ + mode: "offscreen", + width: 800, + height: 600, + }); + assertEquals(renderer.isRunning(), false); + await renderer.initialize(); + assertEquals(renderer.isRunning(), true); + assertEquals(renderer.getMode(), "offscreen"); + renderer.destroy(); + assertEquals(renderer.isRunning(), false); +}); + +Deno.test("WindowRenderer - offscreen present stores pixels", async () => { + const renderer = new WindowRenderer({ + mode: "offscreen", + width: 4, + height: 2, + }); + await renderer.initialize(); + + // Create a small RGBA8 buffer (4x2 = 32 bytes) + const pixels = new Uint8ClampedArray(4 * 2 * 4); + pixels[0] = 255; // R of pixel (0,0) + + const info = renderer.present(pixels, 4, 2); + assertEquals(info.frameNumber, 1); + assertEquals(typeof info.presentTime, "number"); + + const stored = renderer.getPixels(); + assertEquals(stored !== null, true); + assertEquals(stored![0], 255); + assertEquals(renderer.getPixelDimensions(), { width: 4, height: 2 }); + + renderer.destroy(); + assertEquals(renderer.getPixels(), null); +}); + +Deno.test("WindowRenderer - frame counter increments", async () => { + const renderer = new WindowRenderer({ + mode: "offscreen", + width: 2, + height: 2, + }); + await renderer.initialize(); + + const px = new Uint8ClampedArray(2 * 2 * 4); + renderer.present(px, 2, 2); + renderer.present(px, 2, 2); + renderer.present(px, 2, 2); + + assertEquals(renderer.getFrameNumber(), 3); + renderer.destroy(); +}); + +Deno.test("WindowRenderer - resize updates config", async () => { + const renderer = new WindowRenderer({ + mode: "offscreen", + width: 800, + height: 600, + }); + await renderer.initialize(); + renderer.resize(1920, 1080); + // Resize doesn't affect stored pixel dimensions — only affects future renders + assertEquals(renderer.isRunning(), true); + renderer.destroy(); +}); + +Deno.test("WindowRenderer - pollEvents returns empty in offscreen mode", async () => { + const renderer = new WindowRenderer({ + mode: "offscreen", + width: 800, + height: 600, + }); + await renderer.initialize(); + const events = await renderer.pollEvents(); + assertEquals(events.length, 0); + renderer.destroy(); +}); + +Deno.test("WindowRenderer - native mode falls back to offscreen when pixpane unavailable", async () => { + const renderer = new WindowRenderer({ + mode: "native", + title: "Test Window", + width: 800, + height: 600, + }); + await renderer.initialize(); + assertEquals(renderer.isRunning(), true); + + // Present should work (stores in offscreen buffer, pixpane unavailable so no FFI call) + const px = new Uint8ClampedArray(800 * 600 * 4); + const info = renderer.present(px, 800, 600); + assertEquals(info.frameNumber, 1); + assertEquals(renderer.getPixels() !== null, true); + + renderer.destroy(); +}); + +Deno.test("WindowRenderer - initialize is idempotent", async () => { + const renderer = new WindowRenderer({ + mode: "offscreen", + width: 100, + height: 100, + }); + await renderer.initialize(); + await renderer.initialize(); // should not throw + assertEquals(renderer.isRunning(), true); + renderer.destroy(); +}); diff --git a/browser/tests/integration/compositor_pipeline.test.ts b/browser/tests/integration/compositor_pipeline.test.ts new file mode 100644 index 0000000..ddb2e44 --- /dev/null +++ b/browser/tests/integration/compositor_pipeline.test.ts @@ -0,0 +1,286 @@ +/** + * Compositor Pipeline Integration Tests + * + * Verifies that PaintLayer tree → CompositorThread wiring works correctly: + * - updateLayerTree() stores LayerTree + * - CPU composite uses LayerTree path when available + * - CPU composite falls back to RenderToPixels when no LayerTree + * - getPixels() returns correct dimensions + * - Tiling: layers >256px create tiles + * - VSync frame callback fires + * - Layer invalidation on resize + * - Full pipeline: RenderObject → layout → paint → compositor → pixels + */ + +import { assertEquals, assertExists, assertRejects } from "@std/assert"; +import { CompositorThread } from "../../src/engine/rendering/compositor/CompositorThread.ts"; +import { LayerTree, PaintLayer, type LayerID } from "../../src/engine/rendering/paint/PaintLayer.ts"; +import { VSync } from "../../src/engine/rendering/compositor/VSync.ts"; +import { RenderToPixels } from "../../src/engine/rendering/paint/RenderToPixels.ts"; +import { RenderBox } from "../../src/engine/rendering/rendering/RenderBox.ts"; +import { LayoutEngine } from "../../src/engine/rendering/layout/LayoutEngine.ts"; +import type { Pixels } from "../../src/types/identifiers.ts"; +import type { ComputedStyle } from "../../src/types/css.ts"; +import type { DOMElement, HTMLCanvasElement } from "../../src/types/dom.ts"; + +// --- Helpers --- + +function mockStyle(values: Record = {}): ComputedStyle { + return { + getPropertyValue(prop: string): string { return values[prop] || ""; }, + setProperty(): void {}, + getPropertyPriority(): string { return ""; }, + item(): string { return ""; }, + length: 0, + cssText: "", + parentRule: null, + removeProperty(): string { return ""; }, + [Symbol.iterator]: function* () {}, + } as unknown as ComputedStyle; +} + +function mockElement(tagName: string): DOMElement { + return { + tagName, + attributes: new Map(), + children: [], + parentNode: null, + nodeType: 1, + nodeName: tagName, + textContent: "", + } as unknown as DOMElement; +} + +/** + * Create a minimal canvas stub that enters CPU mode (getContext("webgl") returns null) + */ +function createCPUCanvas(width = 800, height = 600): HTMLCanvasElement { + // Backing pixel buffer for 2D context + const pixelData = new Uint8ClampedArray(width * height * 4); + + const ctx2d = { + clearRect(_x: number, _y: number, _w: number, _h: number) { + pixelData.fill(0); + }, + fillRect(x: number, y: number, w: number, h: number) { + // Fill region with current fillStyle color (simplified: mark as non-zero) + const startX = Math.max(0, Math.floor(x)); + const startY = Math.max(0, Math.floor(y)); + const endX = Math.min(width, Math.floor(x + w)); + const endY = Math.min(height, Math.floor(y + h)); + for (let py = startY; py < endY; py++) { + for (let px = startX; px < endX; px++) { + const i = (py * width + px) * 4; + pixelData[i] = 255; // R + pixelData[i + 1] = 0; // G + pixelData[i + 2] = 0; // B + pixelData[i + 3] = 255; // A + } + } + }, + getImageData(sx: number, sy: number, sw: number, sh: number) { + // Return the stored pixel data + const result = new Uint8ClampedArray(sw * sh * 4); + for (let y = 0; y < sh; y++) { + for (let x = 0; x < sw; x++) { + const srcIdx = ((sy + y) * width + (sx + x)) * 4; + const dstIdx = (y * sw + x) * 4; + result[dstIdx] = pixelData[srcIdx] || 0; + result[dstIdx + 1] = pixelData[srcIdx + 1] || 0; + result[dstIdx + 2] = pixelData[srcIdx + 2] || 0; + result[dstIdx + 3] = pixelData[srcIdx + 3] || 0; + } + } + return { data: new Uint8ClampedArray(result), width: sw, height: sh }; + }, + save() {}, + restore() {}, + translate() {}, + rotate() {}, + scale() {}, + set globalAlpha(_v: number) {}, + set globalCompositeOperation(_v: string) {}, + set fillStyle(_v: string) {}, + set strokeStyle(_v: string) {}, + set font(_v: string) {}, + strokeRect() {}, + fillText() {}, + drawImage() {}, + beginPath() {}, + closePath() {}, + moveTo() {}, + lineTo() {}, + arc() {}, + fill() {}, + stroke() {}, + clip() {}, + setTransform() {}, + resetTransform() {}, + measureText() { return { width: 0 }; }, + }; + + return { + width, + height, + getContext(type: string) { + if (type === "webgl") return null; // Force CPU mode + if (type === "2d") return ctx2d; + return null; + }, + toBlob(cb: (blob: unknown) => void) { cb(null); }, + } as unknown as HTMLCanvasElement; +} + +// === Tests === + +Deno.test("Compositor: updateLayerTree stores LayerTree in CPU mode", () => { + const compositor = new CompositorThread(); + const canvas = createCPUCanvas(); + compositor.initialize(canvas); + + assertEquals(compositor.isCPUMode(), true); + + const layerTree = new LayerTree({ x: 0 as Pixels, y: 0 as Pixels, width: 800 as Pixels, height: 600 as Pixels }); + compositor.updateLayerTree(layerTree); + + // Should not throw — layerTree is stored internally + // Verify by compositing (uses layerTree path) + compositor.composite(); +}); + +Deno.test("Compositor: CPU composite uses LayerTree path when available", () => { + const compositor = new CompositorThread(); + const canvas = createCPUCanvas(); + compositor.initialize(canvas); + + const layerTree = new LayerTree({ x: 0 as Pixels, y: 0 as Pixels, width: 800 as Pixels, height: 600 as Pixels }); + compositor.updateLayerTree(layerTree); + + // Should use layerTree.composite(ctx) path, not RenderToPixels + compositor.composite(); + + // Frame count should increment + const stats = compositor.getStats(); + assertEquals(stats.frameCount, 1); +}); + +Deno.test("Compositor: CPU composite falls back to RenderToPixels when no LayerTree", () => { + const compositor = new CompositorThread(); + const canvas = createCPUCanvas(); + compositor.initialize(canvas); + + // Set render tree but no layerTree + const style = mockStyle({ display: "block", width: "400px", height: "200px" }); + const root = new RenderBox(mockElement("div"), style); + const engine = new LayoutEngine(); + engine.layout(root, { width: 800 as Pixels, height: 600 as Pixels }); + + compositor.setRenderTree(root); + compositor.composite(); + + const stats = compositor.getStats(); + assertEquals(stats.frameCount, 1); +}); + +Deno.test("Compositor: CPU composite throws when no LayerTree and no RenderTree", () => { + const compositor = new CompositorThread(); + const canvas = createCPUCanvas(); + compositor.initialize(canvas); + + // No layerTree and no renderTree set — should throw + try { + compositor.composite(); + // Should not reach here + assertEquals(true, false, "Expected composite() to throw"); + } catch (e) { + assertExists(e); + } +}); + +Deno.test("Compositor: getPixels() returns correct dimensions after composite", async () => { + const compositor = new CompositorThread(); + const canvas = createCPUCanvas(100, 80); + compositor.initialize(canvas); + + const layerTree = new LayerTree({ x: 0 as Pixels, y: 0 as Pixels, width: 100 as Pixels, height: 80 as Pixels }); + compositor.updateLayerTree(layerTree); + compositor.composite(); + + const pixels = await compositor.getPixels(); + // RGBA: 100 * 80 * 4 = 32000 bytes + assertEquals(pixels.length, 100 * 80 * 4); +}); + +Deno.test("Compositor: getPixels() auto-composites when layerTree exists but no cpuCanvas", async () => { + const compositor = new CompositorThread(); + const canvas = createCPUCanvas(50, 50); + compositor.initialize(canvas); + + const layerTree = new LayerTree({ x: 0 as Pixels, y: 0 as Pixels, width: 50 as Pixels, height: 50 as Pixels }); + compositor.updateLayerTree(layerTree); + + // Don't call composite() — getPixels() should auto-composite + const pixels = await compositor.getPixels(); + assertEquals(pixels.length, 50 * 50 * 4); +}); + +Deno.test("Compositor: VSync can be configured and reports stats", () => { + const vsync = new VSync(30); + const stats = vsync.getStats(); + assertExists(stats); + assertEquals(typeof stats.averageFPS, "number"); +}); + +Deno.test("Compositor: resize updates canvas dimensions", () => { + const compositor = new CompositorThread(); + const canvas = createCPUCanvas(800, 600); + compositor.initialize(canvas); + + compositor.resize(1024, 768); + + const c = compositor.getCanvas()!; + assertEquals(c.width, 1024); + assertEquals(c.height, 768); +}); + +Deno.test("Compositor: LayerTree getAllLayers returns flat list", () => { + const bounds = { x: 0 as Pixels, y: 0 as Pixels, width: 100 as Pixels, height: 100 as Pixels }; + const tree = new LayerTree(bounds); + const child = tree.createLayer(bounds); + tree.getRoot().addChild(child); + + const layers = tree.getAllLayers(); + assertEquals(layers.length, 2); // root + child +}); + +Deno.test("Compositor: full pipeline RenderObject → layout → paint → compositor → pixels", async () => { + const style = mockStyle({ + display: "block", + width: "100px", + height: "50px", + "background-color": "red", + }); + const root = new RenderBox(mockElement("div"), style); + + const engine = new LayoutEngine(); + engine.layout(root, { width: 200 as Pixels, height: 200 as Pixels }); + + // Paint via RenderToPixels (like orchestrator does) + const rtp = new RenderToPixels(); + const paintResult = rtp.paint(root, 200 as Pixels, 200 as Pixels, false); + assertExists(paintResult.layerTree); + assertExists(paintResult.canvas); + + // Wire into compositor + const compositor = new CompositorThread(); + const canvas = createCPUCanvas(200, 200); + compositor.initialize(canvas); + compositor.updateLayerTree(paintResult.layerTree); + compositor.setRenderTree(root); + compositor.composite(); + + const pixels = await compositor.getPixels(); + assertEquals(pixels.length, 200 * 200 * 4); + + rtp.dispose(); +}); diff --git a/browser/tests/integration/e2e_rendering_pipeline.test.ts b/browser/tests/integration/e2e_rendering_pipeline.test.ts new file mode 100644 index 0000000..a548459 --- /dev/null +++ b/browser/tests/integration/e2e_rendering_pipeline.test.ts @@ -0,0 +1,244 @@ +/** + * End-to-End Rendering Pipeline Tests + * + * Tests the complete path from RenderObject → layout → paint → composite → pixels + * using real components (not mocks of internal logic). + */ + +import { assertEquals, assertExists } from "@std/assert"; +import { RenderBox } from "../../src/engine/rendering/rendering/RenderBox.ts"; +import { RenderText } from "../../src/engine/rendering/rendering/RenderText.ts"; +import { LayoutEngine } from "../../src/engine/rendering/layout/LayoutEngine.ts"; +import { RenderToPixels } from "../../src/engine/rendering/paint/RenderToPixels.ts"; +import { CompositorThread } from "../../src/engine/rendering/compositor/CompositorThread.ts"; +import type { Pixels } from "../../src/types/identifiers.ts"; +import type { ComputedStyle } from "../../src/types/css.ts"; +import type { DOMElement, HTMLCanvasElement } from "../../src/types/dom.ts"; + +// --- Helpers --- + +function mockStyle(values: Record = {}): ComputedStyle { + return { + getPropertyValue(prop: string): string { return values[prop] || ""; }, + setProperty(): void {}, + getPropertyPriority(): string { return ""; }, + item(): string { return ""; }, + length: 0, + cssText: "", + parentRule: null, + removeProperty(): string { return ""; }, + [Symbol.iterator]: function* () {}, + } as unknown as ComputedStyle; +} + +function mockElement(tagName: string): DOMElement { + return { + tagName, + attributes: new Map(), + children: [], + parentNode: null, + nodeType: 1, + nodeName: tagName, + textContent: "", + } as unknown as DOMElement; +} + +function createCPUCanvas(width = 200, height = 200): HTMLCanvasElement { + const pixelData = new Uint8ClampedArray(width * height * 4); + + const ctx2d = { + clearRect() { pixelData.fill(0); }, + fillRect(x: number, y: number, w: number, h: number) { + const startX = Math.max(0, Math.floor(x)); + const startY = Math.max(0, Math.floor(y)); + const endX = Math.min(width, Math.floor(x + w)); + const endY = Math.min(height, Math.floor(y + h)); + for (let py = startY; py < endY; py++) { + for (let px = startX; px < endX; px++) { + const i = (py * width + px) * 4; + pixelData[i] = 255; + pixelData[i + 1] = 0; + pixelData[i + 2] = 0; + pixelData[i + 3] = 255; + } + } + }, + getImageData(sx: number, sy: number, sw: number, sh: number) { + const result = new Uint8ClampedArray(sw * sh * 4); + for (let y = 0; y < sh; y++) { + for (let x = 0; x < sw; x++) { + const srcIdx = ((sy + y) * width + (sx + x)) * 4; + const dstIdx = (y * sw + x) * 4; + result[dstIdx] = pixelData[srcIdx] || 0; + result[dstIdx + 1] = pixelData[srcIdx + 1] || 0; + result[dstIdx + 2] = pixelData[srcIdx + 2] || 0; + result[dstIdx + 3] = pixelData[srcIdx + 3] || 0; + } + } + return { data: new Uint8ClampedArray(result), width: sw, height: sh }; + }, + save() {}, + restore() {}, + translate() {}, + rotate() {}, + scale() {}, + set globalAlpha(_v: number) {}, + set globalCompositeOperation(_v: string) {}, + set fillStyle(_v: string) {}, + set strokeStyle(_v: string) {}, + set font(_v: string) {}, + strokeRect() {}, + fillText() {}, + drawImage() {}, + beginPath() {}, + closePath() {}, + moveTo() {}, + lineTo() {}, + arc() {}, + fill() {}, + stroke() {}, + clip() {}, + setTransform() {}, + resetTransform() {}, + measureText() { return { width: 0 }; }, + }; + + return { + width, + height, + getContext(type: string) { + if (type === "webgl") return null; + if (type === "2d") return ctx2d; + return null; + }, + toBlob(cb: (blob: unknown) => void) { cb(null); }, + } as unknown as HTMLCanvasElement; +} + +function runPipeline(root: RenderBox, w: number, h: number) { + const engine = new LayoutEngine(); + engine.layout(root, { width: w as Pixels, height: h as Pixels }); + + const rtp = new RenderToPixels(); + const paintResult = rtp.paint(root, w as Pixels, h as Pixels, false); + + const compositor = new CompositorThread(); + const canvas = createCPUCanvas(w, h); + compositor.initialize(canvas); + compositor.updateLayerTree(paintResult.layerTree); + compositor.setRenderTree(root); + compositor.composite(); + + return { compositor, paintResult, rtp }; +} + +// === Tests === + +Deno.test("E2E Pipeline: simple div produces pixel output", async () => { + const root = new RenderBox(mockElement("div"), mockStyle({ + display: "block", + width: "100px", + height: "50px", + "background-color": "#ff0000", + })); + + const { compositor, rtp } = runPipeline(root, 200, 200); + const pixels = await compositor.getPixels(); + + assertEquals(pixels.length, 200 * 200 * 4); + // At least some pixels should exist + assertExists(pixels); + + rtp.dispose(); +}); + +Deno.test("E2E Pipeline: nested blocks produce correct layer tree", () => { + const parent = new RenderBox(mockElement("div"), mockStyle({ + display: "block", width: "300px", height: "auto", + })); + const child1 = new RenderBox(mockElement("div"), mockStyle({ + display: "block", height: "50px", "background-color": "blue", + })); + const child2 = new RenderBox(mockElement("div"), mockStyle({ + display: "block", height: "30px", "background-color": "green", + })); + parent.appendChild(child1); + parent.appendChild(child2); + + const { paintResult, rtp } = runPipeline(parent, 400, 400); + + // Layer tree should have at least a root layer + const layers = paintResult.layerTree.getAllLayers(); + assertEquals(layers.length >= 1, true, `Expected at least 1 layer, got ${layers.length}`); + + rtp.dispose(); +}); + +Deno.test("E2E Pipeline: text node in pipeline does not crash", async () => { + const parent = new RenderBox(mockElement("div"), mockStyle({ + display: "block", width: "200px", height: "auto", + })); + const text = new RenderText(mockElement("span"), mockStyle({ + "font-size": "16px", color: "#000", + }), "Hello World"); + parent.appendChild(text); + + const { compositor, rtp } = runPipeline(parent, 200, 100); + const pixels = await compositor.getPixels(); + assertEquals(pixels.length, 200 * 100 * 4); + + rtp.dispose(); +}); + +Deno.test("E2E Pipeline: paint stats report layer and command counts", () => { + const root = new RenderBox(mockElement("div"), mockStyle({ + display: "block", width: "100px", height: "100px", "background-color": "red", + })); + + const { paintResult, rtp } = runPipeline(root, 200, 200); + + assertEquals(paintResult.stats.totalLayers >= 1, true); + assertEquals(typeof paintResult.stats.paintTime, "number"); + assertEquals(typeof paintResult.stats.compositeTime, "number"); + + rtp.dispose(); +}); + +Deno.test("E2E Pipeline: incremental repaint only repaints dirty layers", () => { + const root = new RenderBox(mockElement("div"), mockStyle({ + display: "block", width: "100px", height: "100px", "background-color": "red", + })); + + const engine = new LayoutEngine(); + engine.layout(root, { width: 200 as Pixels, height: 200 as Pixels }); + + const rtp = new RenderToPixels(); + + // First paint — full + const result1 = rtp.paint(root, 200 as Pixels, 200 as Pixels, false); + assertExists(result1.layerTree); + assertEquals(result1.stats.totalLayers >= 1, true); + + // Second paint — incremental (same tree, no mutations) + const result2 = rtp.paint(root, 200 as Pixels, 200 as Pixels, true); + // Both paints complete successfully; incremental path executes without error + assertExists(result2.layerTree); + assertEquals(result2.stats.totalLayers >= 1, true); + + rtp.dispose(); +}); + +Deno.test("E2E Pipeline: compositor frame count increments on each composite", async () => { + const root = new RenderBox(mockElement("div"), mockStyle({ + display: "block", width: "50px", height: "50px", + })); + + const { compositor, rtp } = runPipeline(root, 100, 100); + assertEquals(compositor.getStats().frameCount, 1); + + // Composite again + compositor.composite(); + assertEquals(compositor.getStats().frameCount, 2); + + rtp.dispose(); +}); diff --git a/browser/tests/os/window/window.test.ts b/browser/tests/os/window/window.test.ts index 4d06eb7..9ae1226 100644 --- a/browser/tests/os/window/window.test.ts +++ b/browser/tests/os/window/window.test.ts @@ -84,6 +84,41 @@ Deno.test("WindowContext - present is no-op in headless mode", async () => { win.close(); }); +Deno.test("Window - getWindowId is 0n in headless mode", async () => { + const win = new Window({ title: "Test", width: 800, height: 600 }); + await win.open(); + assertEquals(win.getWindowId(), 0n); + win.close(); +}); + +Deno.test("Window - getPixpane is null in headless mode", async () => { + const win = new Window({ title: "Test", width: 800, height: 600 }); + await win.open(); + assertEquals(win.getPixpane(), null); + win.close(); +}); + +Deno.test("Window - pollEvents returns empty in headless", async () => { + const win = new Window({ title: "Test", width: 800, height: 600 }); + await win.open(); + const events = await win.pollEvents(); + assertEquals(events.length, 0); + win.close(); +}); + +Deno.test("WindowContext - present with pixels in headless increments frameCount", async () => { + const win = new Window({ title: "Test", width: 4, height: 4 }); + await win.open(); + const ctx = new WindowContext(win); + await ctx.initialize(); + const pixels = new Uint8ClampedArray(4 * 4 * 4); + ctx.present(pixels, 4, 4); + ctx.present(pixels, 4, 4); + assertEquals(ctx.getFrameCount(), 2); + ctx.destroy(); + win.close(); +}); + Deno.test("WindowContext - getConfig returns copy", async () => { const win = new Window({ title: "Test", width: 800, height: 600 }); await win.open(); diff --git a/crates/serialx/bindings/bindings.ts b/crates/serialx/bindings/bindings.ts index 26915d3..0ad0e3f 100644 --- a/crates/serialx/bindings/bindings.ts +++ b/crates/serialx/bindings/bindings.ts @@ -1,5 +1,6 @@ // Auto-generated with deno_bindgen -function encode(v: string | Uint8Array): Uint8Array { +// deno-lint-ignore no-explicit-any +function encode(v: string | Uint8Array): any { if (typeof v !== "string") return v return new TextEncoder().encode(v) } @@ -43,6 +44,7 @@ const { symbols } = Deno.dlopen( aix: uri + "libserialx.so", solaris: uri + "libserialx.so", illumos: uri + "libserialx.so", + android: uri + "libserialx.so", }[Deno.build.os], { serialx_bytes_available: { diff --git a/crates/webgpu_x/webgpu_x.ts b/crates/webgpu_x/webgpu_x.ts index 103bf41..921bb93 100644 --- a/crates/webgpu_x/webgpu_x.ts +++ b/crates/webgpu_x/webgpu_x.ts @@ -526,7 +526,7 @@ export class WebGPUX { * @returns Adapter handle or 0n on failure */ requestAdapter(backendType: number): bigint { - return gpu_request_adapter(backendType); + return gpu_request_adapter(backendType) as bigint; } /** @@ -535,7 +535,7 @@ export class WebGPUX { * @returns Device handle or 0n on failure */ requestDevice(adapterHandle: bigint): bigint { - return gpu_request_device(adapterHandle); + return gpu_request_device(adapterHandle) as bigint; } /** @@ -2197,7 +2197,7 @@ export class WebGPUX { * @returns Shader module handle or 0n on failure */ createShaderModule(deviceHandle: bigint, label: string, wgslCode: string): bigint { - return gpu_create_shader_module(deviceHandle, label, wgslCode); + return gpu_create_shader_module(deviceHandle, label, wgslCode) as bigint; } /** @@ -2229,7 +2229,7 @@ export class WebGPUX { deviceHandle, label, vertexModuleHandle, vertexEntryPoint, fragmentModuleHandle, fragmentEntryPoint, format, blendJson, topology, cullMode, layoutMode, - ); + ) as bigint; } /** @@ -2286,7 +2286,7 @@ export class WebGPUX { ): bigint { return gpu_create_compute_pipeline( deviceHandle, label, shaderModuleHandle, entryPoint, layoutMode, - ); + ) as bigint; } /** diff --git a/deno.json b/deno.json index dd62d2e..e2f3cee 100644 --- a/deno.json +++ b/deno.json @@ -16,7 +16,7 @@ "lock": true, "imports": { "@std/assert": "jsr:@std/assert@^1.0.16", - "@browserx/webgpu_x": "jsr:@browserx/webgpu_x", + "@browserx/webgpu_x": "./crates/webgpu_x/webgpu_x.ts", "@browserx/transportx": "./crates/transportx/transportx.ts", "@browserx/bytecodex": "./crates/bytecodex/bytecodex.ts", "@browserx/serialx": "./crates/serialx/serialx.ts"