From f6cd5a6e7a5d3bd0615026dfe2de5e909298038a Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Sun, 4 Oct 2026 12:55:12 -0400 Subject: [PATCH 01/28] Keep one cell size for images and one fallback Images kept their own per-document record of the screen's cell and their own 8 by 16 guess, beside cssom's cell size and fallback and the screen's own guess. The screen now owns the one fallback, cssom keeps the screen's cell beside the page's, and getImageCellSize gives the cell an image's pixels divide by: the page's, or the screen's under the unit cell. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- src/index.ts | 8 +++++--- src/internal/cssom.ts | 31 ++++++++++++++++++++++++++---- src/internal/dom.ts | 7 +++---- src/internal/framebuffer.ts | 11 +++++++---- src/internal/replaced.ts | 38 +++++-------------------------------- 5 files changed, 47 insertions(+), 48 deletions(-) diff --git a/src/index.ts b/src/index.ts index 95635d68..9c89d4bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { getCellSize, setCellSizeSource, setColorSchemeSource, + setScreenCellSource, UNIT_CELL, } from "./internal/cssom.ts"; import { @@ -195,11 +196,11 @@ export class TermDOM { readonly window: Window; constructor(options: TermDOMOptions = {}) { - const cellSize = getCellSizeSource(options.cellSize, () => + const screenCell = () => this[kTransport].interactive ? this[kFramebuffer].cellPixels - : FALLBACK_CELL, - ); + : FALLBACK_CELL; + const cellSize = getCellSizeSource(options.cellSize, screenCell); this[kSealed] = false; this[kRenderQueued] = false; @@ -255,6 +256,7 @@ export class TermDOM { this[kMeasuresCell] = options.cellSize === "auto"; setCellSizeSource(document, cellSize); + setScreenCellSource(document, screenCell); attachDocument( document, diff --git a/src/internal/cssom.ts b/src/internal/cssom.ts index 420c7557..441e4cdd 100644 --- a/src/internal/cssom.ts +++ b/src/internal/cssom.ts @@ -46,7 +46,7 @@ import { isButtonInput, MATHML_NAMESPACE, } from "./dom.ts"; -import {LINE_STYLES, type LineStyle} from "./framebuffer.ts"; +import {FALLBACK_CELL, LINE_STYLES, type LineStyle} from "./framebuffer.ts"; import { getPresentationalHints, isHintAttribute, @@ -71,11 +71,10 @@ export interface CellSize { export const UNIT_CELL: Readonly = {width: 1, height: 1}; -// What "auto" takes when the terminal cannot say. Most terminal fonts -// run about twice as tall as wide. -export const FALLBACK_CELL: Readonly = {width: 8, height: 16}; +export {FALLBACK_CELL}; const cellSizeSources = new WeakMap Readonly>(); +const screenCellSources = new WeakMap Readonly>(); export function setCellSizeSource( document: object, @@ -92,6 +91,30 @@ export function getCellSize(node: Node | null | undefined): Readonly { return cellSizeSources.get(document)?.() ?? UNIT_CELL; } +/** The cell the terminal draws, in its pixels. */ +export function setScreenCellSource( + document: object, + source: () => Readonly, +): void { + screenCellSources.set(document, source); +} + +/** + * The cell an image's pixels divide by. An image's pixels are CSS + * pixels, so this is the page's cell. Under the unit cell that would be + * a cell per pixel, so the screen's cell stands in. + */ +export function getImageCellSize( + node: Node | null | undefined, +): Readonly { + const page = getCellSize(node); + if (page !== UNIT_CELL) { + return page; + } + const document = node?.ownerDocument ?? node; + return (document && screenCellSources.get(document)?.()) ?? FALLBACK_CELL; +} + /** * CSS pixels along one axis as cells, fractions kept. Layout rounds the * edges of the boxes it places, not each length, so lengths that add up diff --git a/src/internal/dom.ts b/src/internal/dom.ts index f62c2019..04992437 100644 --- a/src/internal/dom.ts +++ b/src/internal/dom.ts @@ -30,6 +30,7 @@ import { getBoxModel, getCellSize, getComputedValue, + getImageCellSize, getInlineStyle, getStyleSheets, getWhiteSpace, @@ -67,7 +68,6 @@ import { } from "./images.ts"; import type {Layout} from "./layout.ts"; import {linearizeMath} from "./mathml.ts"; -import {getCellPixels, registerCellPixels} from "./replaced.ts"; import { getNextGraphemeBoundary, getPreviousGraphemeBoundary, @@ -14384,7 +14384,7 @@ class HTMLCanvasElement extends HTMLElement { if (rect && rect.width > 0 && rect.height > 0) { return {x: this.width / rect.width, y: this.height / rect.height}; } - const cell = getCellPixels(this); + const cell = getImageCellSize(this as unknown as Node); return {x: cell.width, y: cell.height}; }, }); @@ -14393,7 +14393,7 @@ class HTMLCanvasElement extends HTMLElement { canvas: this, grid: () => getCanvasGrid(this), changed, - cellPixels: () => getCellPixels(this), + cellPixels: () => getImageCellSize(this as unknown as Node), }); // The natural size is now in cells, not pixels. invalidateReplaced(this, true); @@ -33368,7 +33368,6 @@ export function attachDocument( attached[kFramebuffer] = framebuffer; attached[kPendingCaretReveal] = null; attached[kPendingEditingReveal] = null; - registerCellPixels(attached, () => framebuffer.cellPixels); for (const type of ["input", "select", "change", "selectionchange"]) { exchange.addEventListener(type, onTextControlEditEvent); } diff --git a/src/internal/framebuffer.ts b/src/internal/framebuffer.ts index ea998cd5..a36b0f1e 100644 --- a/src/internal/framebuffer.ts +++ b/src/internal/framebuffer.ts @@ -1790,9 +1790,12 @@ const kFrameScroll = Symbol("frameScroll"); const kDirty = Symbol("dirty"); const kDocumentTop = Symbol("documentTop"); const kCellPixels = Symbol("cellPixels"); -// What a cell measures until the terminal says. Most fonts run about -// twice as tall as wide at any size. -const DEFAULT_CELL_PIXELS = {width: 8, height: 16}; +// What a cell measures in pixels until the terminal says, or when it +// cannot. Most fonts run about twice as tall as wide at any size. +export const FALLBACK_CELL: Readonly<{width: number; height: number}> = { + width: 8, + height: 16, +}; export interface Framebuffer { [kPrev]: CellGrid | null; @@ -1846,7 +1849,7 @@ export class Framebuffer { this[kCols] = cols; this[kScrollTop] = 0; this[kDocumentTop] = 0; - this[kCellPixels] = {...DEFAULT_CELL_PIXELS}; + this[kCellPixels] = {...FALLBACK_CELL}; this[kFrameScroll] = 0; this[kDirty] = true; this[kWriter] = new FrameWriter(colorDepth); diff --git a/src/internal/replaced.ts b/src/internal/replaced.ts index d642e776..07d1a4be 100644 --- a/src/internal/replaced.ts +++ b/src/internal/replaced.ts @@ -14,8 +14,8 @@ import {type CanvasTextRun, halfBlockCell} from "./canvas.ts"; import { getBoxModel, - getCellSize, getComputedValue, + getImageCellSize, toCellLength, } from "./cssom.ts"; import * as CSSValues from "./cssvalues.ts"; @@ -32,34 +32,6 @@ import {getStringWidth} from "./text.ts"; export {isReplacedElement}; -const DEFAULT_CELL_PIXELS = {width: 8, height: 16}; - -const cellPixelSources = new WeakMap< - object, - () => {width: number; height: number} ->(); - -/** Where a document's cell size comes from, once it has a screen. */ -export function registerCellPixels( - document: object, - read: () => {width: number; height: number}, -): void { - cellPixelSources.set(document, read); -} - -export function getCellPixels(element: Element): { - width: number; - height: number; -} { - const page = getCellSize(element as unknown as Node); - if (page.width !== 1 || page.height !== 1) { - return page; - } - const document = element.ownerDocument; - return (document && cellPixelSources.get(document)?.()) ?? - DEFAULT_CELL_PIXELS; -} - /** The natural size in cells, or null for content with none. */ export function getNaturalSize( element: Element, @@ -78,7 +50,7 @@ export function getNaturalSize( if (width === 0 || height === 0) { return {width: 0, height: 0}; } - const cell = getCellPixels(element); + const cell = getImageCellSize(element as unknown as Node); return { width: Math.max(1, Math.round(width / cell.width)), height: Math.max(1, Math.round(height / cell.height)), @@ -100,7 +72,7 @@ export function getNaturalRatio( if (width === 0 || height === 0) { return NaN; } - const cell = getCellPixels(element); + const cell = getImageCellSize(element as unknown as Node); return (width / cell.width) / (height / cell.height); } @@ -370,7 +342,7 @@ function getBitmapCells( under: number | null, ): Array { const {bitmap} = content; - const cell = getCellPixels(element); + const cell = getImageCellSize(element as unknown as Node); const naturalWidth = bitmap.width / cell.width; const naturalHeight = bitmap.height / cell.height; const place = placeImage(element, cols, rows, naturalWidth, naturalHeight); @@ -579,7 +551,7 @@ function paintTextRuns( rows: number, ctx: CellContext, ): void { - const cell = getCellPixels(element); + const cell = getImageCellSize(element as unknown as Node); const place = placeImage( element, cols, From a55f4a7a513f23a4ec1228ec1079b88dd50699f8 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Sun, 4 Oct 2026 13:03:40 -0400 Subject: [PATCH 02/28] Say how images size with and without a cell size Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- docs/guides/07-images-and-canvas.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/guides/07-images-and-canvas.md b/docs/guides/07-images-and-canvas.md index 1f5af696..7bcc59bf 100644 --- a/docs/guides/07-images-and-canvas.md +++ b/docs/guides/07-images-and-canvas.md @@ -39,15 +39,18 @@ candidate. An image that fails to load shows its `alt` text. ### Size -An image's pixels are the screen's pixels, so its natural size in -cells is its size in pixels divided by the size of a cell in pixels. -The engine asks the terminal for the cell size (XTWINOPS 16) and -assumes 8 by 16 until the terminal answers. A 400 by 400 image is 50 -columns by 25 rows under that assumption. - -`width` and `height`, as attributes or in CSS, are cells, like every -other length. With only one of them set, the other follows the image's -ratio. `max-width: 100%` keeps an image inside its container. +An image's pixels are CSS pixels, so its natural size in cells is its +size divided by the size of a cell. With the `cellSize` option set, +that is the page's cell. With the default unit cell, where a CSS pixel +is a whole cell, an image takes the terminal's cell instead: the engine +asks for its size (XTWINOPS 16) and assumes 8 by 16 until the terminal +answers. A 400 by 400 image is 50 columns by 25 rows under that +assumption. + +`width` and `height`, as attributes or in CSS, are lengths like any +other: CSS pixels, which are cells under the unit cell. With only one +of them set, the other follows the image's ratio. `max-width: 100%` +keeps an image inside its container. On a line, an image is as tall as itself and its top is at the top of the line. From eb7d8da61d07acf93ec051766610f7c04edd53f8 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Sun, 4 Oct 2026 13:06:25 -0400 Subject: [PATCH 03/28] Draw charactergrid canvases through CellContext, with lines and boxes The charactergrid context kept its cells in a grid type of its own, and images kept their half blocks in the same type, each copied into the frame a cell at a time. Both are CellGrids now. A charactergrid canvas draws through a CellContext over its grid, an image's cells are cached as a grid, and CellContext.drawGrid copies either into the frame. Through CellContext the context gains its lines: strokeStyle, lineStyle (a CSS border style), lineJoin, strokeLine() and strokeRect(). Lines join in corners, tees and crosses where they meet. The canvas's lines are joined among themselves and drawn as glyphs before they reach the frame, so they do not join the page's borders around the canvas. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- docs/guides/07-images-and-canvas.md | 12 + src/internal/canvas.ts | 369 ++++++++++++++++------------ src/internal/dom.ts | 21 +- src/internal/framebuffer.ts | 85 ++++++- src/internal/images.ts | 22 +- src/internal/replaced.ts | 97 +++----- tests/canvas.test.ts | 58 +++++ 7 files changed, 421 insertions(+), 243 deletions(-) diff --git a/docs/guides/07-images-and-canvas.md b/docs/guides/07-images-and-canvas.md index 7bcc59bf..f2ffb82a 100644 --- a/docs/guides/07-images-and-canvas.md +++ b/docs/guides/07-images-and-canvas.md @@ -131,18 +131,30 @@ grid.fillRect(0, 0, 20, 3); grid.fillStyle = "#ffcc00"; grid.font = "bold"; grid.fillText("score: 42", 1, 1); +grid.strokeStyle = "#5fafff"; +grid.lineJoin = "round"; +grid.strokeRect(0, 0, 20, 3); document.body.append(canvas); ``` +Lines are box-drawing glyphs. Where two meet they join in a corner, a +tee or a cross, as borders do. A canvas's lines join each other but +not the page's borders around the canvas. + | Member | What it does | | --- | --- | | `fillStyle` | A CSS color. `currentcolor`, the default, is the terminal's foreground | +| `strokeStyle` | The lines' color, the same way | +| `lineStyle` | A CSS `border-style`: `solid`, the default, `double`, `dashed`, `dotted` and the rest | +| `lineJoin` | `miter`, the default, or `round`, which curves `strokeRect()`'s corners | | `font` | Keywords from the CSS `font` shorthand: `bold`, `italic`, `lighter` | | `textAlign` | `left`, `right`, `center`, `start` or `end` | | `textDecoration` | `none` or `underline` | | `fillText(text, x, y, maxWidth?)` | Writes glyphs in the fill color, over the cells' backgrounds. A wide glyph takes two columns | | `fillRect(x, y, w, h)` | Fills cells with the fill color as their background. `currentcolor` fills them in inverse video | | `clearRect(x, y, w, h)` | Empties cells | +| `strokeLine(x1, y1, x2, y2)` | A line from one cell to another, both included, along a row or a column. A slanted line draws nothing | +| `strokeRect(x, y, w, h)` | A box around the cells `w` by `h` from (x, y) | | `drawImage(image, ...)` | Draws an image's pixels, two to a cell. Without a size, the image takes its natural size in cells | | `getCell(x, y)` | The cell's glyph, colors and attributes, or `null` | | `measureText(text)` | The text's width in columns | diff --git a/src/internal/canvas.ts b/src/internal/canvas.ts index 85b919c6..807a8ce9 100644 --- a/src/internal/canvas.ts +++ b/src/internal/canvas.ts @@ -13,16 +13,21 @@ * programs draw: one glyph, one color and one background a cell. */ import {parseColor, toCellColor} from "./cssvalues.ts"; +import { + type CellContext, + type CellStyle, + LINE_STYLES, + type LineStyle, + readCell, +} from "./framebuffer.ts"; import { averageBitmap, type Bitmap, - type CharacterGrid, createBitmap, encodePNG, - type GridCell, sampleBitmap, } from "./images.ts"; -import {getStringWidth} from "./text.ts"; +import {getStringWidth, graphemeSegmenter} from "./text.ts"; // --------------------------------------------------------------------------- // What drawImage() and createPattern() take. @@ -2889,7 +2894,8 @@ export function canvasToPNG(bitmap: Bitmap): Uint8Array { /** What the grid context needs from its canvas. */ export interface GridHost { canvas: object; - grid(): CharacterGrid; + // The canvas's cells, `width` columns by `height` rows. + cells(): CellContext; changed(): void; // Terminal pixels per cell, for drawing images at their natural size. cellPixels(): {width: number; height: number}; @@ -2897,39 +2903,45 @@ export interface GridHost { const kGridHost = Symbol("gridHost"); -/** - * getContext("charactergrid"): the canvas as `width` columns by `height` - * rows of cells. fillText() writes glyphs in the fill color over the - * cells' backgrounds, fillRect() fills cells' backgrounds, drawImage() - * draws an image two pixels a cell, and clearRect() empties cells. - * Coordinates are cells. Colors are any CSS color, and `currentcolor`, - * the default, is the terminal's own foreground. - */ const kFillStyle = Symbol("fillStyle"); +const kGridStrokeStyle = Symbol("gridStrokeStyle"); +const kLineStyle = Symbol("lineStyle"); +const kLineJoin = Symbol("lineJoin"); const kFont = Symbol("font"); const kTextAlign = Symbol("textAlign"); const kTextDecoration = Symbol("textDecoration"); -const kColor = Symbol("color"); -const kCell = Symbol("cell"); -const kSet = Symbol("set"); -const kEmptyCell = Symbol("emptyCell"); const kRect = Symbol("rect"); export interface CanvasCharacterGridContext { [kGridHost]: GridHost; [kFillStyle]: string; + [kGridStrokeStyle]: string; + [kLineStyle]: LineStyle["style"]; + [kLineJoin]: "miter" | "round"; [kFont]: string; [kTextAlign]: string; [kTextDecoration]: string; } +/** + * getContext("charactergrid"): the canvas as `width` columns by `height` + * rows of cells. fillText() writes glyphs in the fill color over the + * cells' backgrounds, fillRect() fills cells' backgrounds, strokeLine() + * and strokeRect() draw box-drawing lines that join where they meet, + * drawImage() draws an image two pixels a cell, and clearRect() empties + * cells. Coordinates are cells. Colors are any CSS color, and + * `currentcolor`, the default, is the terminal's own foreground. + */ export class CanvasCharacterGridContext { constructor(host: GridHost) { + this[kGridHost] = host; this[kFillStyle] = "currentcolor"; + this[kGridStrokeStyle] = "currentcolor"; + this[kLineStyle] = "solid"; + this[kLineJoin] = "miter"; this[kFont] = ""; this[kTextAlign] = "left"; this[kTextDecoration] = "none"; - this[kGridHost] = host; } get canvas(): object { @@ -2937,11 +2949,11 @@ export class CanvasCharacterGridContext { } get columns(): number { - return this[kGridHost].grid().cols; + return this[kGridHost].cells().cols; } get rows(): number { - return this[kGridHost].grid().rows; + return this[kGridHost].cells().rows; } get fillStyle(): string { @@ -2949,14 +2961,37 @@ export class CanvasCharacterGridContext { } set fillStyle(value: string) { - const text = String(value).trim().toLowerCase(); - if (text === "currentcolor") { - this[kFillStyle] = "currentcolor"; - return; + this[kFillStyle] = readGridColor(value) ?? this[kFillStyle]; + } + + get strokeStyle(): string { + return this[kGridStrokeStyle]; + } + + set strokeStyle(value: string) { + this[kGridStrokeStyle] = readGridColor(value) ?? this[kGridStrokeStyle]; + } + + /** A CSS border style: solid, double, dashed, dotted and the rest. */ + get lineStyle(): string { + return this[kLineStyle]; + } + + set lineStyle(value: string) { + const style = LINE_STYLES.find((name) => name === value); + if (style !== undefined) { + this[kLineStyle] = style; } - const color = parseCanvasColor(text); - if (color !== null) { - this[kFillStyle] = serializeColor(color); + } + + /** miter, round. A round join curves strokeRect()'s corners. */ + get lineJoin(): string { + return this[kLineJoin]; + } + + set lineJoin(value: string) { + if (value === "miter" || value === "round") { + this[kLineJoin] = value; } } @@ -2992,21 +3027,14 @@ export class CanvasCharacterGridContext { fillRect(x: number, y: number, w: number, h: number): void { const rect = this[kRect](x, y, w, h); - const color = this[kColor](); + const color = toGridColor(this[kFillStyle]); if (rect === null || color === undefined) { return; } const [x0, y0, x1, y1] = rect; - for (let row = y0; row < y1; row++) { - for (let col = x0; col < x1; col++) { - const cell = this[kEmptyCell](); - cell.char = " "; - // The terminal's foreground as a background is its inverse. - cell.bg = color; - cell.inverse = color === null; - this[kSet](col, row, cell); - } - } + // The terminal's foreground as a background is its inverse. + this[kGridHost].cells() + .drawRect(x0, y0, x1 - x0, y1 - y0, color ?? "inverse"); this[kGridHost].changed(); } @@ -3015,11 +3043,11 @@ export class CanvasCharacterGridContext { if (rect === null) { return; } - const [x0, y0, x1, y1] = rect; - for (let row = y0; row < y1; row++) { - for (let col = x0; col < x1; col++) { - this[kSet](col, row, null); - } + const {grid, cols, rows} = this[kGridHost].cells(); + const x0 = Math.max(0, rect[0]); + const x1 = Math.min(cols, rect[2]); + for (let row = Math.max(0, rect[1]); row < Math.min(rows, rect[3]); row++) { + grid.clearRange(row * cols + x0, row * cols + x1); } this[kGridHost].changed(); } @@ -3028,13 +3056,13 @@ export class CanvasCharacterGridContext { if (!isFiniteAll(x, y)) { return; } - const color = this[kColor](); + const color = toGridColor(this[kFillStyle]); if (color === undefined) { return; } - const segmenter = new Intl.Segmenter(undefined, {granularity: "grapheme"}); + const cells = this[kGridHost].cells(); const clusters = [ - ...segmenter.segment(String(text).replace(/[\t\n\f\r]/g, " ")), + ...graphemeSegmenter.segment(String(text).replace(/[\t\n\f\r]/g, " ")), ] .map((segment) => segment.segment) .filter((cluster) => getStringWidth(cluster) > 0); @@ -3054,50 +3082,102 @@ export class CanvasCharacterGridContext { ? col + Math.max(0, Math.floor(maxWidth)) : Infinity; const words = this[kFont].toLowerCase().split(/\s+/); - const bold = words.includes("bold") || words.includes("bolder"); - const italic = words.includes("italic") || words.includes("oblique"); - const dim = words.includes("lighter"); + const base: CellStyle = { + bold: words.includes("bold") || words.includes("bolder"), + italic: words.includes("italic") || words.includes("oblique"), + dim: words.includes("lighter"), + underline: this[kTextDecoration] === "underline", + }; + const {grid} = cells; for (const cluster of clusters) { const columns = getStringWidth(cluster); if (col + columns > limit) { break; } - const previous = this[kCell](col, row); + const index = row * cells.cols + col; + const inside = + row >= 0 && row < cells.rows && col >= 0 && col < cells.cols; // Over a cell filled with the terminal's foreground, which only // inverse video can show, the cell stays inverse: its background - // attribute is then what shows as the glyph's color. - const inverse = previous?.inverse === true; - const cell: GridCell = { - char: cluster, - fg: inverse ? null : color, - bg: inverse ? color : previous?.bg ?? null, - bold, - italic, - underline: this[kTextDecoration] === "underline", - dim, - inverse, - }; - this[kSet](col, row, cell); - // A wide glyph's second column is covered by it. + // is then what shows as the glyph's color. + const inverse = + inside && + grid.cluster[index] !== 0 && + readCell(grid, index)?.inverse === true; + cells.drawText( + cluster, + col, + row, + inverse ? {...base, inverse: true, bg: color} : {...base, fg: color}, + ); + // A wide glyph covers the columns after its first. for (let extra = 1; extra < columns; extra++) { - const next = this[kCell](col + extra, row); - this[kSet](col + extra, row, { - ...this[kEmptyCell](), - char: "", - bg: next?.bg ?? null, - }); + if (inside && col + extra < cells.cols) { + grid.cluster[index + extra] = 0; + } } col += columns; } this[kGridHost].changed(); } - measureText(text: string): {width: number} { - let width = 0; - for (const char of String(text)) { - width += Math.max(0, getStringWidth(char)); + /** + * A line from the cell at (x1, y1) to the cell at (x2, y2), both + * included, in strokeStyle and lineStyle. A line meeting another + * joins it in a corner, a tee or a cross. Only rows and columns: a + * slanted line draws nothing. + */ + strokeLine(x1: number, y1: number, x2: number, y2: number): void { + if (!isFiniteAll(x1, y1, x2, y2)) { + return; } - return {width}; + const color = toGridColor(this[kGridStrokeStyle]); + if (color === undefined) { + return; + } + const [a, b, c, d] = [x1, y1, x2, y2].map(Math.round); + const line: LineStyle = {style: this[kLineStyle], color}; + const cells = this[kGridHost].cells(); + if (b === d) { + cells.drawLine(Math.min(a, c), b, Math.max(a, c) + 1, b, { + ...line, + startCap: a === c ? "square" : undefined, + endCap: a === c ? "square" : undefined, + }); + } else if (a === c) { + cells.drawLine(a, Math.min(b, d), a, Math.max(b, d) + 1, line); + } else { + return; + } + this[kGridHost].changed(); + } + + /** A box on the cells `w` by `h` from (x, y), in strokeStyle and lineStyle. */ + strokeRect(x: number, y: number, w: number, h: number): void { + const rect = this[kRect](x, y, w, h); + const color = toGridColor(this[kGridStrokeStyle]); + if (rect === null || color === undefined) { + return; + } + const [x0, y0, x1, y1] = rect; + const side: LineStyle = {style: this[kLineStyle], color}; + const corner = this[kLineJoin] === "round" ? "round" : undefined; + this[kGridHost].cells() + .drawBox(x0, y0, x1 - x0, y1 - y0, { + top: side, + right: side, + bottom: side, + left: side, + topLeft: corner, + topRight: corner, + bottomRight: corner, + bottomLeft: corner, + }); + this[kGridHost].changed(); + } + + measureText(text: string): {width: number} { + return this[kGridHost].cells().measureText(String(text)); } /** @@ -3147,22 +3227,26 @@ export class CanvasCharacterGridContext { }); const left = Math.round(dx); const top = Math.round(dy); + const cells = this[kGridHost].cells(); + const {grid} = cells; for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { - const upper = (row * 2 * cols + col) * 4; - const lower = ((row * 2 + 1) * cols + col) * 4; - const previous = this[kCell](left + col, top + row); - if (previous === undefined) { + const x = left + col; + const y = top + row; + if (x < 0 || y < 0 || x >= cells.cols || y >= cells.rows) { continue; } - const cellOut = halfBlockCell( + const index = y * cells.cols + x; + const half = halfBlockCell( pixels, - upper, - lower, - previous?.bg ?? null, + (row * 2 * cols + col) * 4, + ((row * 2 + 1) * cols + col) * 4, + grid.cluster[index] !== 0 && grid.bg[index] !== 0 + ? grid.bg[index] + : null, ); - if (cellOut !== null) { - this[kSet](left + col, top + row, cellOut); + if (half !== null) { + cells.drawText(half.char, x, y, half.style); } } } @@ -3181,12 +3265,18 @@ export class CanvasCharacterGridContext { italic: boolean; underline: boolean; } | null { - const cell = this[kCell](Math.floor(x), Math.floor(y)); - if (cell == null) { + const {grid, cols, rows} = this[kGridHost].cells(); + const col = Math.floor(x); + const row = Math.floor(y); + if (col < 0 || row < 0 || col >= cols || row >= rows) { + return null; + } + const cell = readCell(grid, row * cols + col); + if (cell === null) { return null; } - const css = (value: number | null) => { - if (value === null) { + const css = (value: number) => { + if (value === 0) { return null; } const rgb = value & 0xffffff; @@ -3198,7 +3288,7 @@ export class CanvasCharacterGridContext { }); }; return { - char: cell.char ?? "", + char: cell.char, color: css(cell.fg), background: css(cell.bg), bold: cell.bold, @@ -3208,57 +3298,17 @@ export class CanvasCharacterGridContext { } reset(): void { - const grid = this[kGridHost].grid(); - grid.cells.fill(null); + this[kGridHost].cells().grid.clear(); this[kFillStyle] = "currentcolor"; + this[kGridStrokeStyle] = "currentcolor"; + this[kLineStyle] = "solid"; + this[kLineJoin] = "miter"; this[kFont] = ""; this[kTextAlign] = "left"; this[kTextDecoration] = "none"; this[kGridHost].changed(); } - // The fill color as a cell color, null for the terminal's foreground, - // or undefined for a transparent color. - [kColor](): number | null | undefined { - if (this[kFillStyle] === "currentcolor") { - return null; - } - const color = parseCanvasColor(this[kFillStyle])!; - if (color.a === 0) { - return undefined; - } - return toCellColor((color.r << 16) | (color.g << 8) | color.b); - } - - [kCell](x: number, y: number): GridCell | null | undefined { - const grid = this[kGridHost].grid(); - if (x < 0 || y < 0 || x >= grid.cols || y >= grid.rows) { - return undefined; - } - return grid.cells[y * grid.cols + x]; - } - - [kSet](x: number, y: number, cell: GridCell | null): void { - const grid = this[kGridHost].grid(); - if (x < 0 || y < 0 || x >= grid.cols || y >= grid.rows) { - return; - } - grid.cells[y * grid.cols + x] = cell; - } - - [kEmptyCell](): GridCell { - return { - char: null, - fg: null, - bg: null, - bold: false, - italic: false, - underline: false, - dim: false, - inverse: false, - }; - } - [kRect]( x: number, y: number, @@ -3280,20 +3330,44 @@ export class CanvasCharacterGridContext { } } +// A color as the grid context keeps it, serialized, or null when the +// value is not a color. +function readGridColor(value: string): string | null { + const text = String(value).trim().toLowerCase(); + if (text === "currentcolor") { + return "currentcolor"; + } + const color = parseCanvasColor(text); + return color === null ? null : serializeColor(color); +} + +// A kept color as a cell color: null for the terminal's foreground, or +// undefined for a transparent one, which draws nothing. +function toGridColor(value: string): number | null | undefined { + if (value === "currentcolor") { + return null; + } + const color = parseCanvasColor(value)!; + if (color.a === 0) { + return undefined; + } + return toCellColor((color.r << 16) | (color.g << 8) | color.b); +} + /** * The cell two stacked pixels make: the upper half block in the upper * pixel's color over the lower pixel's, a lower half block when only the * lower pixel shows, a space in one color when both match, and null when * neither shows. A pixel at least half opaque shows; a partly * transparent one is first blended over `under` when there is a color - * under it. + * under it. A half block with no background keeps the one under it. */ export function halfBlockCell( pixels: Uint8ClampedArray, upper: number, lower: number, under: number | null, -): GridCell | null { +): {char: string; style: CellStyle} | null { const read = (at: number): number | null => { const alpha = pixels[at + 3] / 255; let r = pixels[at]; @@ -3316,27 +3390,14 @@ export function halfBlockCell( }; const top = read(upper); const bottom = read(lower); - const cell: GridCell = { - char: "▀", - fg: top, - bg: bottom, - bold: false, - italic: false, - underline: false, - dim: false, - inverse: false, - }; if (top === null && bottom === null) { return null; } if (top === null) { - cell.char = "▄"; - cell.fg = bottom; - cell.bg = null; - } else if (top === bottom) { - cell.char = " "; - cell.fg = null; - cell.bg = top; - } - return cell; + return {char: "▄", style: {fg: bottom}}; + } + if (top === bottom) { + return {char: " ", style: {bg: top}}; + } + return {char: "▀", style: {fg: top, bg: bottom ?? undefined}}; } diff --git a/src/internal/dom.ts b/src/internal/dom.ts index 04992437..08246a7d 100644 --- a/src/internal/dom.ts +++ b/src/internal/dom.ts @@ -51,7 +51,7 @@ import { import * as CSSValues from "./cssvalues.ts"; import {getEditingHost, installEditing} from "./editing.ts"; import type {Exchange} from "./exchange.ts"; -import type {Framebuffer} from "./framebuffer.ts"; +import {CellContext, CellGrid, type Framebuffer} from "./framebuffer.ts"; import { getInterface, getReflections, @@ -59,7 +59,6 @@ import { } from "./htmlreflection.ts"; import { type Bitmap, - type CharacterGrid, createBitmap, decodeImage, fetchImageBytes, @@ -14275,7 +14274,7 @@ class HTMLCanvasElement extends HTMLElement { declare height: globalThis.HTMLCanvasElement["height"]; declare width: globalThis.HTMLCanvasElement["width"]; [kCanvasBitmap]: Bitmap | null; - [kCanvasGrid]: CharacterGrid | null; + [kCanvasGrid]: CellContext | null; [kCanvasContext]: CanvasRenderingContext2D | CanvasCharacterGridContext | null; @@ -14294,7 +14293,7 @@ class HTMLCanvasElement extends HTMLElement { if (this[kCanvasContext] instanceof CanvasCharacterGridContext) { return { kind: "grid", - grid: getCanvasGrid(this), + grid: getCanvasGrid(this).grid, version: this[kCanvasVersion], }; } @@ -14391,7 +14390,7 @@ class HTMLCanvasElement extends HTMLElement { } else if (id === "charactergrid") { this[kCanvasContext] = new CanvasCharacterGridContext({ canvas: this, - grid: () => getCanvasGrid(this), + cells: () => getCanvasGrid(this), changed, cellPixels: () => getImageCellSize(this as unknown as Node), }); @@ -14496,17 +14495,17 @@ function getCanvasBitmap(canvas: HTMLCanvasElement): Bitmap { return bitmap; } -function getCanvasGrid(canvas: HTMLCanvasElement): CharacterGrid { +function getCanvasGrid(canvas: HTMLCanvasElement): CellContext { const fits = canvas.width * canvas.height <= CANVAS_PIXEL_LIMIT; const cols = fits ? canvas.width : 0; const rows = fits ? canvas.height : 0; - let grid = canvas[kCanvasGrid]; - if (grid === null || grid.cols !== cols || grid.rows !== rows) { - grid = + let cells = canvas[kCanvasGrid]; + if (cells === null || cells.cols !== cols || cells.rows !== rows) { + cells = canvas[kCanvasGrid] = - {cols, rows, cells: new Array(cols * rows).fill(null)}; + new CellContext(new CellGrid(rows, cols), rows, cols, 0); } - return grid; + return cells; } // Resizing a canvas resets its context to the default state (HTML diff --git a/src/internal/framebuffer.ts b/src/internal/framebuffer.ts index a36b0f1e..5113168c 100644 --- a/src/internal/framebuffer.ts +++ b/src/internal/framebuffer.ts @@ -710,7 +710,7 @@ function getBorderChar(borderEncoding: number): string { * two-column glyph covers. The glyph's width text control steps the emitter * over it. */ -class CellGrid { +export class CellGrid { readonly rows: number; readonly cols: number; readonly cluster: Uint32Array; @@ -768,6 +768,12 @@ class CellGrid { this.border.set(source.border.subarray(start, end), to); } + clone(): CellGrid { + const copy = new CellGrid(this.rows, this.cols); + copy.copyFrom(this, {to: 0, start: 0, end: this.rows * this.cols}); + return copy; + } + bottomRows(rows: number): CellGrid { const kept = new CellGrid(rows, this.cols); kept.copyFrom(this, { @@ -863,6 +869,52 @@ function meetEdges(existing: number, incoming: number): number { return met; } +/** + * A grid's lines joined where they touch and drawn as their glyphs, so + * the grid copies into another without joining what is there. + */ +export function settleBorders(grid: CellGrid): void { + joinTouchingBorders(grid); + const {border, cluster} = grid; + for (let index = 0; index < border.length; index++) { + if (border[index] > 0) { + cluster[index] = encodeGrapheme(getBorderChar(border[index])); + border[index] = 0; + } + } +} + +/** A cell's glyph, colors and attributes, or null for an empty cell. */ +export function readCell( + grid: CellGrid, + index: number, +): { + char: string; + fg: number; + bg: number; + bold: boolean; + italic: boolean; + underline: boolean; + inverse: boolean; +} | null { + if (grid.cluster[index] === 0) { + return null; + } + const attrs = grid.attrs[index]; + const border = grid.border[index]; + return { + char: border > 0 + ? getBorderChar(border) + : decodeGrapheme(grid.cluster[index]), + fg: grid.fg[index], + bg: grid.bg[index], + bold: (attrs & ATTR.Bold) !== 0, + italic: (attrs & ATTR.Italic) !== 0, + underline: (attrs & (ATTR.Underline | ATTR.DoubleUnderline)) !== 0, + inverse: (attrs & ATTR.Inverse) !== 0, + }; +} + function joinTouchingBorders(grid: CellGrid): void { const {rows, cols, border} = grid; const painted = border.slice(); @@ -1119,6 +1171,37 @@ export class CellContext { } } + /** + * Another grid's cells with its top left at (x, y). An empty cell + * leaves what is under it, one with no background takes the + * background under it, and one in the default foreground takes + * `fallback.fg`. + */ + drawGrid(source: CellGrid, x: number, y: number, fallback?: CellStyle): void { + const grid = this.grid; + const fg = (fallback?.fg ?? 0) & COLOR_MASK; + for (let row = 0; row < source.rows; row++) { + for (let col = 0; col < source.cols; col++) { + const from = row * source.cols + col; + if (source.cluster[from] === 0) { + continue; + } + const index = getGuardedWriteIndex(this, y + row, x + col); + if (index < 0) { + continue; + } + const under = grid.cluster[index] !== 0 ? grid.bg[index] : 0; + grid.setFrom(index, source, from); + if (grid.fg[index] === 0) { + grid.fg[index] = fg; + } + if (grid.bg[index] === 0) { + grid.bg[index] = under; + } + } + } + } + drawDecoration(x: number, y: number, width: number, style: CellStyle): void { // An edge across existing glyphs, which drawText cannot draw because it // overwrites. The style's fg is the row's default. A cell with its own diff --git a/src/internal/images.ts b/src/internal/images.ts index 36c89bb2..ef91c2fa 100644 --- a/src/internal/images.ts +++ b/src/internal/images.ts @@ -12,6 +12,7 @@ * and the canvas and the painter read it back through the registry at * the bottom. */ +import type {CellGrid} from "./framebuffer.ts"; /** Straight (not premultiplied) RGBA, four bytes a pixel, rows top down. */ export interface Bitmap { @@ -1835,29 +1836,10 @@ export type ReplacedContent = } | // A canvas not drawn on yet: its size, and nothing to paint. {kind: "blank"; width: number; height: number} | - {kind: "grid"; grid: CharacterGrid; version: number} | + {kind: "grid"; grid: CellGrid; version: number} | {kind: "text"; text: string} | null; -/** One cell of a character grid. A null char leaves the cell's text alone. */ -export interface GridCell { - char: string | null; - fg: number | null; - bg: number | null; - bold: boolean; - italic: boolean; - underline: boolean; - dim: boolean; - // Painted in the terminal's own colors, swapped. - inverse: boolean; -} - -export interface CharacterGrid { - cols: number; - rows: number; - cells: Array; -} - const replacedSources = new WeakMap ReplacedContent>(); export function registerReplacedContent( diff --git a/src/internal/replaced.ts b/src/internal/replaced.ts index 07d1a4be..72503d76 100644 --- a/src/internal/replaced.ts +++ b/src/internal/replaced.ts @@ -19,11 +19,15 @@ import { toCellLength, } from "./cssom.ts"; import * as CSSValues from "./cssvalues.ts"; -import type {CellContext, CellStyle} from "./framebuffer.ts"; +import { + CellContext, + CellGrid, + type CellStyle, + settleBorders, +} from "./framebuffer.ts"; import { type Bitmap, getReplacedContent, - type GridCell, isReplacedElement, type ReplacedContent, sampleBitmap, @@ -327,7 +331,7 @@ function placeImage( interface CellCache { key: string; - cells: Array; + cells: CellGrid; } const bitmapCells = new WeakMap(); @@ -340,7 +344,7 @@ function getBitmapCells( cols: number, rows: number, under: number | null, -): Array { +): CellGrid { const {bitmap} = content; const cell = getImageCellSize(element as unknown as Node); const naturalWidth = bitmap.width / cell.width; @@ -396,49 +400,39 @@ function getBitmapCells( ); } } - const cells: Array = []; + const cells = new CellContext(new CellGrid(rows, cols), rows, cols, 0); for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { - cells.push( - halfBlockCell( - pixels, - (row * 2 * cols + col) * 4, - ((row * 2 + 1) * cols + col) * 4, - under, - ), + const half = halfBlockCell( + pixels, + (row * 2 * cols + col) * 4, + ((row * 2 + 1) * cols + col) * 4, + under, ); + if (half !== null) { + cells.drawText(half.char, col, row, half.style); + } } } - bitmapCells.set(bitmap, {key, cells}); - return cells; + bitmapCells.set(bitmap, {key, cells: cells.grid}); + return cells.grid; } -function drawCell( - ctx: CellContext, - cell: GridCell, - x: number, - y: number, - fallback: CellStyle, -): void { - // The second column of a wide glyph belongs to the glyph before it. - if (cell.char === "") { - return; - } - if (cell.char === null) { - if (cell.bg !== null) { - ctx.drawRect(x, y, 1, 1, cell.bg); - } - return; +const settledGrids = new WeakMap(); + +// A charactergrid's cells with its lines joined among themselves and +// drawn, kept until the canvas draws again. +function getSettledGrid( + content: Extract, +): CellGrid { + const cached = settledGrids.get(content.grid); + if (cached?.version === content.version) { + return cached.grid; } - ctx.drawText(cell.char, x, y, { - fg: cell.fg ?? fallback.fg, - bg: cell.bg ?? undefined, - bold: cell.bold || undefined, - italic: cell.italic || undefined, - underline: cell.underline || undefined, - dim: cell.dim || undefined, - inverse: cell.inverse || undefined, - }); + const grid = content.grid.clone(); + settleBorders(grid); + settledGrids.set(content.grid, {version: content.version, grid}); + return grid; } /** @@ -502,25 +496,14 @@ export function renderReplaced( if (content.kind === "text") { ctx.drawText(content.text, left, top, style); } else if (content.kind === "grid") { - const {grid} = content; - for (let row = 0; row < Math.min(rows, grid.rows); row++) { - for (let col = 0; col < Math.min(cols, grid.cols); col++) { - const cell = grid.cells[row * grid.cols + col]; - if (cell !== null) { - drawCell(ctx, cell, left + col, top + row, style); - } - } - } + ctx.drawGrid(getSettledGrid(content), left, top, style); } else { - const cells = getBitmapCells(element, content, cols, rows, under); - for (let row = 0; row < rows; row++) { - for (let col = 0; col < cols; col++) { - const cell = cells[row * cols + col]; - if (cell !== null) { - drawCell(ctx, cell, left + col, top + row, style); - } - } - } + ctx.drawGrid( + getBitmapCells(element, content, cols, rows, under), + left, + top, + style, + ); if (textRuns.length > 0) { paintTextRuns( element, diff --git a/tests/canvas.test.ts b/tests/canvas.test.ts index be3a0cfe..80ef4181 100644 --- a/tests/canvas.test.ts +++ b/tests/canvas.test.ts @@ -849,3 +849,61 @@ test("text over a currentcolor fill stays in inverse video", async () => { expect(cell(terminal, 0, 0)).toMatchObject({char: " ", inverse: true}); dom.dispose(); }); + +test("strokeRect draws a box and strokeLine joins it as tees", async () => { + const {dom, terminal, document} = + await mount(""); + const ctx = grid(document); + ctx.strokeRect(0, 0, 6, 4); + ctx.strokeLine(0, 2, 5, 2); + ctx.strokeLine(3, 0, 3, 2); + await nextFrame(dom); + expect(rowText(terminal, 0)).toBe("┌──┬─┐"); + expect(rowText(terminal, 1)).toBe("│ │ │"); + expect(rowText(terminal, 2)).toBe("├──┴─┤"); + expect(rowText(terminal, 3)).toBe("└────┘"); + expect(ctx.getCell(3, 2).char).toBe("┴"); + dom.dispose(); +}); + +test("lineStyle, lineJoin and strokeStyle set how lines draw", async () => { + const {dom, terminal, document} = + await mount(""); + const ctx = grid(document); + ctx.lineStyle = "double"; + ctx.strokeRect(0, 0, 4, 3); + ctx.lineStyle = "solid"; + ctx.lineJoin = "round"; + ctx.strokeStyle = "#ff0000"; + ctx.strokeRect(0, 3, 4, 3); + ctx.lineStyle = "wavy"; + expect(ctx.lineStyle).toBe("solid"); + await nextFrame(dom); + expect(rowText(terminal, 0)).toBe("╔══╗"); + expect(rowText(terminal, 2)).toBe("╚══╝"); + expect(rowText(terminal, 3)).toBe("╭──╮"); + expect(cell(terminal, 1, 3)).toMatchObject({char: "─", fg: 0xff0000}); + expect(cell(terminal, 1, 0).fg).toBe(-1); + dom.dispose(); +}); + +test("a slanted line draws nothing", async () => { + const {dom, document} = + await mount(""); + const ctx = grid(document); + ctx.strokeLine(0, 0, 3, 3); + expect(ctx.getCell(0, 0)).toBeNull(); + dom.dispose(); +}); + +test("a canvas's lines do not join the page's borders beside it", async () => { + const {dom, terminal, document} = await mount( + "
" + + "
", + ); + const ctx = grid(document); + ctx.strokeLine(0, 1, 0, 1); + await nextFrame(dom); + expect(rowText(terminal, 2).slice(0, 2)).toBe("│─"); + dom.dispose(); +}); From d2d112132248c22fcf6625ed4c997031ce6be94e Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Sun, 4 Oct 2026 13:15:01 -0400 Subject: [PATCH 04/28] Refuse images that ask for more than an image may be An image file names its size before its pixels, so a few bytes could ask for any amount of memory or work. As a browser does, the engine now checks before it allocates: - Every decoder rejects a size past 32768 pixels to a side or 2^26 in all, read from the header before any pixel buffer exists. - A PNG's data inflates only as far as its image needs. The rest is ignored, as browsers ignore it, instead of read whole. - A JPEG has one to four components sampled one to four times, and at most 500 scans, libjpeg-turbo's suggested limit. - A GIF frame's code size is 2 to 8 bits, and its data is gathered as bytes rather than an array of numbers. - data:, file: and fetched images stop at 64 MB, by the declared length when there is one and while reading when there is not. - A new src aborts the request the old one started. - ImageData past 2^26 pixels throws a RangeError, a charactergrid's drawImage past it draws nothing, an image box that would sample past it paints nothing, and a character grid stops at 2^22 cells. Each test but one fails without these changes. The inflate test passes either way: it checks that data past the image is ignored. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- docs/guides/07-images-and-canvas.md | 16 +++ src/internal/canvas.ts | 8 +- src/internal/dom.ts | 19 ++- src/internal/images.ts | 203 ++++++++++++++++++++++++---- src/internal/replaced.ts | 5 +- tests/canvas.test.ts | 23 ++++ tests/images.test.ts | 154 +++++++++++++++++++++ tests/img.test.ts | 33 +++++ 8 files changed, 424 insertions(+), 37 deletions(-) diff --git a/docs/guides/07-images-and-canvas.md b/docs/guides/07-images-and-canvas.md index f2ffb82a..cee3b9eb 100644 --- a/docs/guides/07-images-and-canvas.md +++ b/docs/guides/07-images-and-canvas.md @@ -69,6 +69,22 @@ A pixel at least half opaque shows, and the rest leave the cell under them as it was. On an element with a `background-color`, translucent pixels blend over that color instead. +### Limits + +An image file names its size before its pixels, so a few bytes can +ask for gigabytes. As a browser does, the engine checks what a file +asks for before it allocates, and an image past a limit fails to load: + +- a file of at most 64 MB, whether `data:`, `file:` or fetched +- at most 32768 pixels to a side and 2^26 pixels in all +- a PNG's data inflated only as far as its image needs +- a JPEG of at most four components and 500 scans + +A new `src` cancels the request the old one started. An image whose +box would sample more than 2^26 pixels draws nothing, as does a 2D +canvas past that size. `ImageData` past it throws a `RangeError`, and +a character grid stops at 2^22 cells. + ## `` `getContext()` takes two context types. diff --git a/src/internal/canvas.ts b/src/internal/canvas.ts index 807a8ce9..7b1757c1 100644 --- a/src/internal/canvas.ts +++ b/src/internal/canvas.ts @@ -25,6 +25,7 @@ import { type Bitmap, createBitmap, encodePNG, + MAX_IMAGE_PIXELS, sampleBitmap, } from "./images.ts"; import {getStringWidth, graphemeSegmenter} from "./text.ts"; @@ -88,6 +89,11 @@ export class ImageData { "The width and height must be positive", ); } + if (width * height > MAX_IMAGE_PIXELS) { + throw new RangeError( + `ImageData is limited to ${MAX_IMAGE_PIXELS} pixels`, + ); + } data = new Uint8ClampedArray(width * height * 4); } else { data = dataOrWidth; @@ -3216,7 +3222,7 @@ export class CanvasCharacterGridContext { } const cols = Math.round(dw); const rows = Math.round(dh); - if (cols <= 0 || rows <= 0) { + if (cols <= 0 || rows <= 0 || cols * rows * 2 > MAX_IMAGE_PIXELS) { return; } const pixels = sampleBitmap(bitmap, cols, rows * 2, { diff --git a/src/internal/dom.ts b/src/internal/dom.ts index 08246a7d..e1722bf5 100644 --- a/src/internal/dom.ts +++ b/src/internal/dom.ts @@ -62,6 +62,7 @@ import { createBitmap, decodeImage, fetchImageBytes, + MAX_IMAGE_PIXELS, registerReplacedContent, resolveImageURL, } from "./images.ts"; @@ -14476,16 +14477,16 @@ class HTMLCanvasElement extends HTMLElement { } // The bitmap, made at the canvas's size when first needed. -// Past this many pixels a canvas gets no bitmap and draws nothing, as a +// Past MAX_IMAGE_PIXELS a canvas gets no bitmap and draws nothing, as a // browser's canvas does past its own limit, rather than failing to -// allocate. -const CANVAS_PIXEL_LIMIT = 1 << 26; +// allocate. A cell takes 18 bytes, so a character grid stops sooner. +const GRID_CELL_LIMIT = 1 << 22; // The bitmap, made at the canvas's size when first drawn on. function getCanvasBitmap(canvas: HTMLCanvasElement): Bitmap { const width = canvas.width; const height = canvas.height; - if (width * height > CANVAS_PIXEL_LIMIT) { + if (width * height > MAX_IMAGE_PIXELS) { return createBitmap(0, 0); } let bitmap = canvas[kCanvasBitmap]; @@ -14496,7 +14497,7 @@ function getCanvasBitmap(canvas: HTMLCanvasElement): Bitmap { } function getCanvasGrid(canvas: HTMLCanvasElement): CellContext { - const fits = canvas.width * canvas.height <= CANVAS_PIXEL_LIMIT; + const fits = canvas.width * canvas.height <= GRID_CELL_LIMIT; const cols = fits ? canvas.width : 0; const rows = fits ? canvas.height : 0; let cells = canvas[kCanvasGrid]; @@ -16467,6 +16468,8 @@ interface ImageState { // Bumped with each new request, so a slow one that finishes after a // newer one started is dropped. generation: number; + // Cancels the request in flight when a newer one replaces it. + abort: AbortController | null; version: number; waiters: Array<{resolve: () => void; reject: (error: unknown) => void}>; // A source set while the document had no window, loaded on insertion @@ -16549,6 +16552,7 @@ class HTMLImageElement extends HTMLElement { url: "", bitmap: null, generation: 0, + abort: null, version: 0, waiters: [], pending: false, @@ -16714,6 +16718,8 @@ function readImageDimension( function updateImageData(image: HTMLImageElement): void { const state = image[kImageState]; const generation = ++state.generation; + state.abort?.abort(); + state.abort = null; // decode() calls waiting on the request this one replaces reject, as // HTML's "update the image data" says. for (const waiter of state.waiters.splice(0)) { @@ -16786,7 +16792,8 @@ function updateImageData(image: HTMLImageElement): void { // a src does not collapse the box in between. state.status = "loading"; invalidateReplaced(image, false); - fetchImageBytes(url) + const abort = state.abort = new AbortController(); + fetchImageBytes(url, abort.signal) .then((bytes) => decodeImage(bytes)) .then( (bitmap) => settle("complete", bitmap), diff --git a/src/internal/images.ts b/src/internal/images.ts index ef91c2fa..6268a054 100644 --- a/src/internal/images.ts +++ b/src/internal/images.ts @@ -21,6 +21,40 @@ export interface Bitmap { data: Uint8ClampedArray; } +/** + * The largest image anything here allocates: 32768 pixels to a side and + * 2^26 in all, 256 MB as RGBA. A file states its size before its + * pixels, so a few bytes can ask for any amount of memory. Like a + * browser, a decoder refuses a size past these before it allocates. + */ +export const MAX_IMAGE_SIDE = 32768; +export const MAX_IMAGE_PIXELS = 1 << 26; + +/** Throws for a size an image may not have. */ +export function checkImageSize( + width: number, + height: number, + format: string, +): void { + if ( + !Number.isInteger(width) || + !Number.isInteger(height) || + width <= 0 || + height <= 0 + ) { + throw new Error(`The ${format} image has no size`); + } + if ( + width > MAX_IMAGE_SIDE || + height > MAX_IMAGE_SIDE || + width * height > MAX_IMAGE_PIXELS + ) { + throw new Error( + `The ${format} image is ${width} by ${height} pixels, more than an image may be`, + ); + } +} + export function createBitmap(width: number, height: number): Bitmap { return {width, height, data: new Uint8ClampedArray(width * height * 4)}; } @@ -89,11 +123,26 @@ function readUint32(bytes: Uint8Array, offset: number): number { // zlib, which is what CompressionStream calls "deflate". Node, Bun, Deno // and browsers all have it, and nothing else here needs a platform API. -async function inflate(data: Uint8Array): Promise { - const stream = new Blob([data as BlobPart]) +// Reading stops at `limit` bytes: deflate shrinks a run of zeros a +// thousandfold, so what a stream would inflate to is no bound at all. +async function inflate(data: Uint8Array, limit: number): Promise { + const reader = new Blob([data as BlobPart]) .stream() - .pipeThrough(new DecompressionStream("deflate")); - return new Uint8Array(await new Response(stream).arrayBuffer()); + .pipeThrough(new DecompressionStream("deflate")) + .getReader(); + const out = new Uint8Array(limit); + let length = 0; + while (length < limit) { + const {done, value} = await reader.read(); + if (done) { + break; + } + const take = Math.min(value.length, limit - length); + out.set(value.subarray(0, take), length); + length += take; + } + await reader.cancel().catch(() => {}); + return out.subarray(0, length); } // Adam7: the start and step of each pass, columns then rows. @@ -107,6 +156,28 @@ const ADAM7 = [ [0, 1, 1, 2], ]; +// The bytes a PNG's image data inflates to: each row's filter byte and +// samples, over each Adam7 pass when interlaced. +function getPNGDataSize( + width: number, + height: number, + bitsPerPixel: number, + interlaced: boolean, +): number { + const rowSize = (columns: number) => + columns === 0 ? 0 : 1 + Math.ceil((columns * bitsPerPixel) / 8); + if (!interlaced) { + return height * rowSize(width); + } + let size = 0; + for (const [x0, y0, dx, dy] of ADAM7) { + const columns = Math.ceil(Math.max(0, width - x0) / dx); + const rows = Math.ceil(Math.max(0, height - y0) / dy); + size += rows * rowSize(columns); + } + return size; +} + async function decodePNG(bytes: Uint8Array): Promise { for (let i = 0; i < 8; i++) { if (bytes[i] !== PNG_SIGNATURE[i]) { @@ -178,6 +249,7 @@ async function decodePNG(bytes: Uint8Array): Promise { if (channels === undefined || ![1, 2, 4, 8, 16].includes(depth)) { throw new Error("The PNG image has an unknown color type or depth"); } + checkImageSize(width, height, "PNG"); let joined = parts[0]; if (parts.length > 1) { joined = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); @@ -187,9 +259,12 @@ async function decodePNG(bytes: Uint8Array): Promise { at += part.length; } } - const raw = await inflate(joined); - const bitmap = createBitmap(width, height); const bitsPerPixel = channels * depth; + const raw = await inflate( + joined, + getPNGDataSize(width, height, bitsPerPixel, interlace === 1), + ); + const bitmap = createBitmap(width, height); const bytesPerPixel = Math.max(1, bitsPerPixel >> 3); const max = (1 << depth) - 1; @@ -409,6 +484,9 @@ export function encodePNG(bitmap: Bitmap): Uint8Array { // factors, grayscale, YCbCr, and Adobe's RGB, CMYK and YCCK. Arithmetic // coding and lossless JPEG are rare enough to be left out. +// libjpeg-turbo's suggested limit. A progressive image has ten or so. +const MAX_JPEG_SCANS = 500; + const ZIGZAG = new Int32Array([ 0, 1, @@ -985,6 +1063,7 @@ function decodeJPEG(data: Uint8Array): Bitmap { const acTables: HuffmanTable[] = []; let frame: JPEGFrame | null = null; let resetInterval = 0; + let scans = 0; let adobeTransform = -1; let jfif = false; let orientation = 1; @@ -1059,6 +1138,12 @@ function decodeJPEG(data: Uint8Array): Bitmap { const height = (data[start + 1] << 8) | data[start + 2]; const width = (data[start + 3] << 8) | data[start + 4]; const count = data[start + 5]; + checkImageSize(width, height, "JPEG"); + // Each component is a plane the size of the image, and an image + // is gray, YCbCr or CMYK. + if (count < 1 || count > 4) { + throw new Error("The JPEG image has more than four components"); + } const components: JPEGComponent[] = []; let maxH = 1; let maxV = 1; @@ -1066,6 +1151,9 @@ function decodeJPEG(data: Uint8Array): Bitmap { const at = start + 6 + i * 3; const h = data[at + 1] >> 4 || 1; const v = data[at + 1] & 15 || 1; + if (h > 4 || v > 4) { + throw new Error("The JPEG image samples a component past four"); + } maxH = Math.max(maxH, h); maxV = Math.max(maxV, v); components.push({ @@ -1082,9 +1170,6 @@ function decodeJPEG(data: Uint8Array): Bitmap { pred: 0, }); } - if (width === 0 || height === 0) { - throw new Error("The JPEG image has no size"); - } const mcusPerLine = Math.ceil(width / (8 * maxH)); const mcusPerColumn = Math.ceil(height / (8 * maxV)); for (const component of components) { @@ -1116,6 +1201,13 @@ function decodeJPEG(data: Uint8Array): Bitmap { if (frame === null) { throw new Error("The JPEG scan comes before its frame"); } + // Each scan passes over the whole image, so a file of a few + // kilobytes could ask for any number of passes. + if (++scans > MAX_JPEG_SCANS) { + throw new Error( + `The JPEG image has more than ${MAX_JPEG_SCANS} scans`, + ); + } const count = data[start]; const scanComponents: JPEGComponent[] = []; for (let i = 0; i < count; i++) { @@ -1414,20 +1506,28 @@ function decodeGIF(data: Uint8Array): Bitmap { if (table === null) { throw new Error("The GIF image has no color table"); } + checkImageSize(frameWidth, frameHeight, "GIF"); + checkImageSize(width || frameWidth, height || frameHeight, "GIF"); const minCodeSize = data[offset++]; - const chunks: number[] = []; + if (minCodeSize < 2 || minCodeSize > 8) { + throw new Error("The GIF frame's code size is not 2 to 8 bits"); + } + const chunks: Uint8Array[] = []; + let length = 0; while (offset < data.length && data[offset] !== 0) { const size = data[offset++]; - for (let i = 0; i < size; i++) { - chunks.push(data[offset + i]); - } + const chunk = data.subarray(offset, offset + size); + chunks.push(chunk); + length += chunk.length; offset += size; } - const indices = decodeLZW( - Uint8Array.from(chunks), - minCodeSize, - frameWidth * frameHeight, - ); + const joined = new Uint8Array(length); + let at = 0; + for (const chunk of chunks) { + joined.set(chunk, at); + at += chunk.length; + } + const indices = decodeLZW(joined, minCodeSize, frameWidth * frameHeight); const bitmap = createBitmap(width || frameWidth, height || frameHeight); const interlaced = (flags & 0x40) !== 0; const rowOrder = interlaced ? interlacedRows(frameHeight) : null; @@ -1555,9 +1655,7 @@ function decodeBMP(data: Uint8Array): Bitmap { const compression = headerSize >= 40 ? view.getUint32(30, true) : 0; const height = Math.abs(rawHeight); const bottomUp = rawHeight > 0; - if (width <= 0 || height === 0) { - throw new Error("The BMP image has no size"); - } + checkImageSize(width, height, "BMP"); const rle = (compression === 1 && bits === 8) || (compression === 2 && bits === 4); if (compression !== 0 && compression !== 3 && !rle) { @@ -1926,7 +2024,8 @@ export function resolveImageURL(src: string, base: string): string | null { } interface FileSystem { - readFile(path: URL): Promise; + readFile(path: URL, options?: {signal?: AbortSignal}): Promise; + stat(path: URL): Promise<{size: number}>; } // Node's file system, which Bun and Deno provide too. getBuiltinModule @@ -1950,25 +2049,71 @@ async function getFileSystem(): Promise { } } +/** The most bytes an image file may have before it decodes. */ +export const MAX_IMAGE_BYTES = 1 << 26; + +function checkImageBytes(length: number): void { + if (length > MAX_IMAGE_BYTES) { + throw new Error(`The image file is more than ${MAX_IMAGE_BYTES} bytes`); + } +} + /** - * The bytes a URL names. data: is read here. file: goes through the - * runtime's file system where it has one (Node's fetch does not read - * files), and anything else through fetch. + * The bytes a URL names, up to MAX_IMAGE_BYTES. data: is read here. + * file: goes through the runtime's file system where it has one (Node's + * fetch does not read files), and anything else through fetch, which + * `signal` cancels. */ -export async function fetchImageBytes(url: string): Promise { +export async function fetchImageBytes( + url: string, + signal?: AbortSignal, +): Promise { const inline = readDataURL(url); if (inline !== null) { + checkImageBytes(inline.length); return inline; } if (url.startsWith("file:")) { const fs = await getFileSystem(); if (fs !== undefined) { - return new Uint8Array(await fs.readFile(new URL(url))); + const path = new URL(url); + checkImageBytes((await fs.stat(path)).size); + return new Uint8Array(await fs.readFile(path, {signal})); } } - const response = await fetch(url); + const response = await fetch(url, {signal}); if (!response.ok) { throw new Error(`The image request failed with status ${response.status}`); } - return new Uint8Array(await response.arrayBuffer()); + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared)) { + checkImageBytes(declared); + } + if (response.body === null) { + return new Uint8Array(0); + } + // The length a server declares is not a promise. The body is read + // a piece at a time and given up past the limit. + const reader = response.body.getReader(); + const pieces: Uint8Array[] = []; + let length = 0; + for (;;) { + const {done, value} = await reader.read(); + if (done) { + break; + } + length += value.length; + if (length > MAX_IMAGE_BYTES) { + await reader.cancel().catch(() => {}); + checkImageBytes(length); + } + pieces.push(value); + } + const bytes = new Uint8Array(length); + let at = 0; + for (const piece of pieces) { + bytes.set(piece, at); + at += piece.length; + } + return bytes; } diff --git a/src/internal/replaced.ts b/src/internal/replaced.ts index 72503d76..d37f8299 100644 --- a/src/internal/replaced.ts +++ b/src/internal/replaced.ts @@ -29,6 +29,7 @@ import { type Bitmap, getReplacedContent, isReplacedElement, + MAX_IMAGE_PIXELS, type ReplacedContent, sampleBitmap, } from "./images.ts"; @@ -497,7 +498,9 @@ export function renderReplaced( ctx.drawText(content.text, left, top, style); } else if (content.kind === "grid") { ctx.drawGrid(getSettledGrid(content), left, top, style); - } else { + } else if (cols * rows * 2 <= MAX_IMAGE_PIXELS) { + // Past the limit the pixels a box samples are more than an image + // may hold, and like a canvas past it, the box shows nothing. ctx.drawGrid( getBitmapCells(element, content, cols, rows, under), left, diff --git a/tests/canvas.test.ts b/tests/canvas.test.ts index 80ef4181..f6cdb712 100644 --- a/tests/canvas.test.ts +++ b/tests/canvas.test.ts @@ -907,3 +907,26 @@ test("a canvas's lines do not join the page's borders beside it", async () => { expect(rowText(terminal, 2).slice(0, 2)).toBe("│─"); dom.dispose(); }); + +test("ImageData past the pixel limit throws, and so does getImageData", async () => { + const {dom, document} = + await mount(""); + const ctx = + (document.getElementById("c") as HTMLCanvasElement).getContext("2d")!; + expect(() => ctx.createImageData(1 << 14, 1 << 13)).toThrow(RangeError); + expect(() => ctx.getImageData(0, 0, 1 << 14, 1 << 13)).toThrow(RangeError); + dom.dispose(); +}); + +test("a charactergrid draws nothing for an image sized past the pixel limit", async () => { + const {dom, document} = await mount( + "", + ); + const source = + (document.getElementById("src") as HTMLCanvasElement).getContext("2d")!; + source.fillRect(0, 0, 2, 2); + const ctx = grid(document); + ctx.drawImage(document.getElementById("src"), 0, 0, 1e6, 1e6); + expect(ctx.getCell(0, 0)).toBeNull(); + dom.dispose(); +}); diff --git a/tests/images.test.ts b/tests/images.test.ts index a5feaf40..37a198ed 100644 --- a/tests/images.test.ts +++ b/tests/images.test.ts @@ -14,6 +14,8 @@ import { type Bitmap, decodeImage, encodePNG, + fetchImageBytes, + MAX_IMAGE_BYTES, readDataURL, resolveImageURL, sampleBitmap, @@ -320,3 +322,155 @@ test("a relative src resolves against the document, or the working directory", ( expect(local?.startsWith("file:///")).toBe(true); expect(local?.endsWith("/art/cover.png")).toBe(true); }); + +// Images that ask for more than they are: a header's size, a stream +// that inflates past its image, more scans or components than an image +// has, or more bytes than an image file may have. + +function patched(name: string, at: number, ...values: number[]): Uint8Array { + const bytes = bytesOf(name).slice(); + bytes.set(values, at); + return bytes; +} + +function findMarker(bytes: Uint8Array, marker: number): number { + for (let i = 2; i < bytes.length - 1; i++) { + if (bytes[i] === 0xff && bytes[i + 1] === marker) { + return i; + } + } + throw new Error(`no marker ${marker.toString(16)}`); +} + +test("a size no image may have rejects before it is allocated", async () => { + const big = [0x00, 0x01, 0x86, 0xa0]; + await expect(decodeImage(patched("png-rgb.png", 16, ...big, ...big))) + .rejects.toThrow("more than an image may be"); + await expect(decodeImage(patched("png-rgb.png", 16, 0, 0, 0x9c, 0x40))) + .rejects.toThrow("more than an image may be"); + const sof = findMarker(bytesOf("jpeg-444.jpg"), 0xc0); + await expect( + decodeImage(patched("jpeg-444.jpg", sof + 5, 0xff, 0xff, 0xff, 0xff)), + ).rejects.toThrow("more than an image may be"); + await expect(decodeImage(patched("gif.gif", 6, 0xff, 0xff, 0xff, 0xff))) + .rejects.toThrow("more than an image may be"); + await expect( + decodeImage( + patched("bmp-24.bmp", 18, 0xa0, 0x86, 0x01, 0x00, 0xa0, 0x86, 0x01, 0x00), + ), + ).rejects.toThrow("more than an image may be"); +}); + +test("a JPEG with more components, sampling or scans than an image has rejects", async () => { + const sof = findMarker(bytesOf("jpeg-444.jpg"), 0xc0); + await expect(decodeImage(patched("jpeg-444.jpg", sof + 9, 5))) + .rejects.toThrow("more than four components"); + await expect(decodeImage(patched("jpeg-444.jpg", sof + 11, 0x55))) + .rejects.toThrow("past four"); + // The last scan again and again, ahead of the end marker. + const bytes = bytesOf("jpeg-progressive.jpg"); + let last = -1; + for (let at = findMarker(bytes, 0xda); at !== -1;) { + last = at; + let next = -1; + for (let i = at + 2; i < bytes.length - 1; i++) { + if (bytes[i] === 0xff && bytes[i + 1] === 0xda) { + next = i; + break; + } + } + at = next; + } + const end = bytes.length - 2; + const scan = bytes.subarray(last, end); + const many = new Uint8Array(last + scan.length * 501 + 2); + many.set(bytes.subarray(0, last)); + for (let i = 0; i < 501; i++) { + many.set(scan, last + i * scan.length); + } + many.set([0xff, 0xd9], many.length - 2); + await expect(decodeImage(many)).rejects.toThrow("more than 500 scans"); +}); + +test("a GIF frame's code size past 8 bits rejects", async () => { + const bytes = bytesOf("gif.gif"); + const descriptor = bytes.indexOf(0x2c, 13); + const flags = bytes[descriptor + 9]; + const table = flags & 0x80 ? 3 * (1 << ((flags & 7) + 1)) : 0; + await expect(decodeImage(patched("gif.gif", descriptor + 10 + table, 12))) + .rejects.toThrow("code size"); +}); + +test("PNG data that inflates past its image is read only as far as the image", async () => { + // 16 by 16 gray, filter byte 0 and a ramp on each row, and then + // megabytes of zeros the image has no room for. + const raw = new Uint8Array(16 * 17 + (1 << 24)); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + raw[y * 17 + 1 + x] = x * 16; + } + } + const deflated = new Uint8Array( + await new Response( + new Blob([raw]).stream().pipeThrough(new CompressionStream("deflate")), + ).arrayBuffer(), + ); + const header = new Uint8Array(13); + header.set([0, 0, 0, 16, 0, 0, 0, 16, 8, 0, 0, 0, 0]); + const chunk = (type: string, data: Uint8Array) => { + const out = new Uint8Array(12 + data.length); + new DataView(out.buffer).setUint32(0, data.length); + out.set([...type].map((c) => c.charCodeAt(0)), 4); + out.set(data, 8); + return out; + }; + const parts = [ + new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", header), + chunk("IDAT", deflated), + chunk("IEND", new Uint8Array(0)), + ]; + const png = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); + let at = 0; + for (const part of parts) { + png.set(part, at); + at += part.length; + } + const bitmap = await decodeImage(png); + expect([bitmap.width, bitmap.height]).toEqual([16, 16]); + expect(pixel(bitmap, 15, 15)).toEqual([240, 240, 240, 255]); +}); + +test("an image file past the byte limit is not read whole", async () => { + const original = globalThis.fetch; + const signals: Array = []; + const piece = new Uint8Array(1 << 20); + let pulled = 0; + try { + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + signals.push(init?.signal ?? undefined); + if (signals.length === 1) { + return new Response(piece, { + headers: {"content-length": String(MAX_IMAGE_BYTES + 1)}, + }); + } + return new Response( + new ReadableStream({ + pull(controller) { + pulled++; + controller.enqueue(piece); + }, + }), + ); + }) as typeof fetch; + const abort = new AbortController(); + await expect(fetchImageBytes("https://example.com/a.png", abort.signal)) + .rejects.toThrow("more than"); + expect(signals[0]).toBe(abort.signal); + await expect(fetchImageBytes("https://example.com/b.png")) + .rejects.toThrow("more than"); + expect(pulled).toBeLessThanOrEqual(MAX_IMAGE_BYTES / piece.length + 2); + } finally { + globalThis.fetch = original; + } +}); diff --git a/tests/img.test.ts b/tests/img.test.ts index 240ea554..daa6fe0e 100644 --- a/tests/img.test.ts +++ b/tests/img.test.ts @@ -628,3 +628,36 @@ test("with a cell size, an image's pixels and lengths are CSS pixels", async () expect(cells("ratio")).toEqual([15, 3]); dom.dispose(); }); + +test("a new src cancels the request the old one started", async () => { + const original = globalThis.fetch; + const signals: AbortSignal[] = []; + try { + globalThis.fetch = ((_url: unknown, init?: RequestInit) => { + const signal = init!.signal!; + signals.push(signal); + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason)); + }); + }) as typeof fetch; + const {dom, document} = await mount(""); + const image = document.getElementById("i") as HTMLImageElement; + image.src = "https://example.com/first.png"; + image.src = "https://example.com/second.png"; + expect(signals.map((signal) => signal.aborted)).toEqual([true, false]); + dom.dispose(); + } finally { + globalThis.fetch = original; + } +}); + +test("an image whose box is past the pixel limit paints nothing", async () => { + const square = png(4, 4, () => RED); + const {dom, terminal, document} = await mount( + ``, + ); + await loaded(document.querySelector("img")!); + await nextFrame(dom); + expect(cell(terminal, 0, 0).bg).toBe(-1); + dom.dispose(); +}); From d5eec6480d5b5b10f1de8d5faf5a27bedc70eb67 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Sun, 4 Oct 2026 13:36:10 -0400 Subject: [PATCH 05/28] Decode images on a worker, with a timeout A 12-megapixel JPEG held the page's thread for about 840 ms while it decoded, and the largest image allowed would hold it for seconds. Images now decode on a worker: src/decode-worker.ts, built beside index.js as its own entry, under a web Worker where the runtime has one and Node's worker_threads where it does not. The page's longest stall for that JPEG is 10 to 25 ms on Node, Bun and Deno. A decode that runs past 10 seconds ends the worker, which ends the decode, and the image fails; a new worker takes the next image. An idle worker is ended after a second and never keeps the process alive. Where no worker starts (a CommonJS build has no import.meta.url, and libuild's test bundles have no worker file beside them) decoding stays on the page's thread, and the PNG and JPEG decoders hand the event loop a turn every 16 ms. A worker that started and then failed fails its images instead: what crashed it would crash the page too. The built package was checked on all three runtimes with a stand-in worker whose answers only it could give. The tests use workers from data: URLs for the timeout, an error, and a worker that cannot start. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- docs/guides/07-images-and-canvas.md | 5 + package.json | 1 + src/decode-worker.ts | 66 ++++++ src/index.ts | 13 ++ src/internal/dom.ts | 6 +- src/internal/images.ts | 324 ++++++++++++++++++++++++++-- tests/images.test.ts | 53 +++++ 7 files changed, 450 insertions(+), 18 deletions(-) create mode 100644 src/decode-worker.ts diff --git a/docs/guides/07-images-and-canvas.md b/docs/guides/07-images-and-canvas.md index cee3b9eb..b68b123c 100644 --- a/docs/guides/07-images-and-canvas.md +++ b/docs/guides/07-images-and-canvas.md @@ -80,6 +80,11 @@ asks for before it allocates, and an image past a limit fails to load: - a PNG's data inflated only as far as its image needs - a JPEG of at most four components and 500 scans +Images decode on a worker thread, so a large one does not stall the +page, and a decode that takes more than 10 seconds is stopped and the +image fails. Where no worker can start, as in a CommonJS build, +images decode on the page's thread a slice at a time. + A new `src` cancels the request the old one started. An image whose box would sample more than 2^26 pixels draws nothing, as does a 2D canvas past that size. `ImageData` past it throws a `RangeError`, and diff --git a/package.json b/package.json index 9ed06c32..86c3094b 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ ], "exports": { ".": "./src/index.ts", + "./decode-worker": "./src/decode-worker.ts", "./package.json": "./package.json" }, "files": [ diff --git a/src/decode-worker.ts b/src/decode-worker.ts new file mode 100644 index 00000000..3c2eb6f5 --- /dev/null +++ b/src/decode-worker.ts @@ -0,0 +1,66 @@ +/** + * The thread images decode on. It takes `{id, bytes}` and answers + * `{id, width, height, data}` with the pixels' buffer transferred, or + * `{id, error}`. It answers `{ready: true}` first, so the page can tell + * a worker that never started from one that failed on an image. + * + * A web worker answers through its global scope. Node's worker_threads + * answer through parentPort. + */ +import {decodeImage} from "./internal/images.ts"; + +interface Port { + postMessage(message: unknown, transfer?: Transferable[]): void; + on?(type: "message", listener: (data: unknown) => void): void; + addEventListener?( + type: "message", + listener: (event: {data: unknown}) => void, + ): void; +} + +interface WorkerThreads { + parentPort?: Port | null; +} + +interface NodeProcess { + getBuiltinModule?(id: string): WorkerThreads | undefined; +} + +function getNodeParent(): Port | null { + const process = (globalThis as {process?: NodeProcess}).process; + if (process?.getBuiltinModule === undefined) { + return null; + } + return process.getBuiltinModule("node:worker_threads")?.parentPort ?? null; +} + +const nodeParent = getNodeParent(); +const port: Port = nodeParent ?? (globalThis as unknown as Port); + +async function answer(data: unknown): Promise { + const {id, bytes} = data as {id: number; bytes: ArrayBuffer}; + try { + const bitmap = await decodeImage(new Uint8Array(bytes)); + const pixels = bitmap.data.buffer as ArrayBuffer; + port.postMessage( + {id, width: bitmap.width, height: bitmap.height, data: pixels}, + [pixels], + ); + } catch (error) { + port.postMessage({ + id, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +if (nodeParent !== null) { + nodeParent.on!("message", (data) => { + answer(data).catch(() => {}); + }); +} else { + port.addEventListener!("message", (event) => { + answer(event.data).catch(() => {}); + }); +} +port.postMessage({ready: true}); diff --git a/src/index.ts b/src/index.ts index 9c89d4bd..6c76a831 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,7 @@ import { transportFromProcess, } from "./internal/exchange.ts"; import {Framebuffer} from "./internal/framebuffer.ts"; +import {setDecodeWorkerURL} from "./internal/images.ts"; import {Input} from "./internal/input.ts"; import {Layout} from "./internal/layout.ts"; import {Painter} from "./internal/painter.ts"; @@ -59,6 +60,18 @@ export type { TerminalTransport, } from "./internal/exchange.ts"; +// Images decode on a worker running src/decode-worker.ts, which builds to +// dist/decode-worker.js beside this file. A CommonJS build has no +// import.meta.url, and decodes on this thread instead. +setDecodeWorkerURL( + typeof import.meta.url === "string" + ? new URL( + `./decode-worker.${import.meta.url.endsWith(".ts") ? "ts" : "js"}`, + import.meta.url, + ) + : null, +); + export interface TermDOMOptions { /** Defaults to the global process. */ diff --git a/src/internal/dom.ts b/src/internal/dom.ts index e1722bf5..265fc8d8 100644 --- a/src/internal/dom.ts +++ b/src/internal/dom.ts @@ -60,7 +60,7 @@ import { import { type Bitmap, createBitmap, - decodeImage, + decodeImageOffThread, fetchImageBytes, MAX_IMAGE_PIXELS, registerReplacedContent, @@ -16794,7 +16794,7 @@ function updateImageData(image: HTMLImageElement): void { invalidateReplaced(image, false); const abort = state.abort = new AbortController(); fetchImageBytes(url, abort.signal) - .then((bytes) => decodeImage(bytes)) + .then((bytes) => decodeImageOffThread(bytes)) .then( (bitmap) => settle("complete", bitmap), (error) => settle("broken", null, error), @@ -35546,7 +35546,7 @@ export class Window extends EventTarget { // A Blob is decoded first; an image still loading is waited for. if (typeof Blob !== "undefined" && image instanceof Blob) { return image.arrayBuffer() - .then((buffer) => decodeImage(new Uint8Array(buffer))) + .then((buffer) => decodeImageOffThread(new Uint8Array(buffer))) .then((bitmap) => settle(new ImageBitmap(bitmap))); } if (image instanceof HTMLImageElement && !image.complete) { diff --git a/src/internal/images.ts b/src/internal/images.ts index 6268a054..99bf8378 100644 --- a/src/internal/images.ts +++ b/src/internal/images.ts @@ -55,6 +55,27 @@ export function checkImageSize( } } +// A browser decodes on another thread. On one, a decoder hands the event +// loop a turn this often, so a large image does not stall the page +// while it decodes. +const DECODE_SLICE_MS = 16; + +// Returns a promise to await when a turn is due, and undefined between. +function createYielder(): () => Promise | undefined { + let last = performance.now(); + return () => { + if (performance.now() - last < DECODE_SLICE_MS) { + return undefined; + } + return new Promise((resolve) => { + setTimeout(() => { + last = performance.now(); + resolve(); + }, 0); + }); + }; +} + export function createBitmap(width: number, height: number): Bitmap { return {width, height, data: new Uint8ClampedArray(width * height * 4)}; } @@ -97,7 +118,7 @@ export async function decodeImage(bytes: Uint8Array): Promise { case "image/png": return await decodePNG(bytes); case "image/jpeg": - return decodeJPEG(bytes); + return await decodeJPEG(bytes); case "image/gif": return decodeGIF(bytes); case "image/bmp": @@ -323,7 +344,8 @@ async function decodePNG(bytes: Uint8Array): Promise { }; let at = 0; - const unfilter = ( + const pause = createYielder(); + const unfilter = async ( passWidth: number, passHeight: number, emit: (line: Uint8Array, y: number) => void, @@ -366,6 +388,7 @@ async function decodePNG(bytes: Uint8Array): Promise { } emit(current, y); [previous, current] = [current, previous]; + await pause(); } }; @@ -376,12 +399,12 @@ async function decodePNG(bytes: Uint8Array): Promise { if (passWidth <= 0 || passHeight <= 0) { continue; } - unfilter(passWidth, passHeight, (line, y) => + await unfilter(passWidth, passHeight, (line, y) => store(line, passWidth, x0, dx, y0 + y * dy), ); } } else { - unfilter(width, height, (line, y) => store(line, width, 0, 1, y)); + await unfilter(width, height, (line, y) => store(line, width, 0, 1, y)); } return bitmap; } @@ -745,7 +768,7 @@ class BitReader { } } -function decodeScan( +async function decodeScan( data: Uint8Array, offset: number, frame: JPEGFrame, @@ -755,7 +778,8 @@ function decodeScan( spectralEnd: number, successivePrev: number, successive: number, -): number { + pause: () => Promise | undefined, +): Promise { const reader = new BitReader(data, offset); const progressive = frame.progressive; let eobrun = 0; @@ -921,6 +945,10 @@ function decodeScan( eobrun = 0; successiveState = 0; for (let n = 0; n < interval && mcu < total; n++, mcu++) { + const turn = (mcu & 63) === 0 ? pause() : undefined; + if (turn !== undefined) { + await turn; + } if (components.length === 1) { const component = components[0]; const row = Math.floor(mcu / component.blocksPerLine); @@ -1057,7 +1085,8 @@ function readExifOrientation( return 1; } -function decodeJPEG(data: Uint8Array): Bitmap { +async function decodeJPEG(data: Uint8Array): Promise { + const pause = createYielder(); const quantTables: Uint16Array[] = []; const dcTables: HuffmanTable[] = []; const acTables: HuffmanTable[] = []; @@ -1222,7 +1251,7 @@ function decodeJPEG(data: Uint8Array): Bitmap { scanComponents.push(component); } const at = start + 1 + count * 2; - offset = decodeScan( + offset = await decodeScan( data, end, frame, @@ -1232,6 +1261,7 @@ function decodeJPEG(data: Uint8Array): Bitmap { data[at + 1], data[at + 2] >> 4, data[at + 2] & 15, + pause, ); continue; } @@ -1252,20 +1282,28 @@ function decodeJPEG(data: Uint8Array): Bitmap { throw new Error("The JPEG image has no frame"); } return orient( - buildJPEGBitmap(frame, quantTables, adobeTransform, jfif), + await buildJPEGBitmap(frame, quantTables, adobeTransform, jfif, pause), orientation, ); } -function buildJPEGBitmap( +async function buildJPEGBitmap( frame: JPEGFrame, quantTables: Uint16Array[], adobeTransform: number, jfif: boolean, -): Bitmap { + pause: () => Promise | undefined, +): Promise { const {width, height, components} = frame; // Each component's samples at its own resolution. - const planes = components.map((component) => { + const planes: Array<{ + plane: Uint8ClampedArray; + planeWidth: number; + planeHeight: number; + scaleX: number; + scaleY: number; + }> = []; + for (const component of components) { const planeWidth = component.blocksPerLineForMcu * 8; const rows = (component.blocks.length / 64 / component.blocksPerLineForMcu) * 8; @@ -1279,6 +1317,7 @@ function buildJPEGBitmap( const blocksPerRow = component.blocksPerLineForMcu; const blockRows = rows / 8; for (let blockRow = 0; blockRow < blockRows; blockRow++) { + await pause(); for (let blockCol = 0; blockCol < blocksPerRow; blockCol++) { inverseDCT( component.blocks, @@ -1295,14 +1334,14 @@ function buildJPEGBitmap( } } } - return { + planes.push({ plane, planeWidth, planeHeight: rows, scaleX: component.h / frame.maxH, scaleY: component.v / frame.maxV, - }; - }); + }); + } const bitmap = createBitmap(width, height); const data = bitmap.data; @@ -1321,6 +1360,7 @@ function buildJPEGBitmap( ); const sample = new Float32Array(4); for (let y = 0; y < height; y++) { + await pause(); for (let x = 0; x < width; x++) { for (let c = 0; c < count; c++) { const {plane, planeWidth, planeHeight, scaleX, scaleY} = planes[c]; @@ -2117,3 +2157,257 @@ export async function fetchImageBytes( } return bytes; } + +// --------------------------------------------------------------------------- +// Decoding off the page's thread. + +/** + * How long an image may take to decode on the worker. Past it, the + * worker is ended, which ends the decode with it, and the image fails. + */ +let decodeTimeout = 10_000; +// A worker with nothing to do is ended, so it keeps no process alive. +const WORKER_IDLE_MS = 1_000; + +let workerURL: URL | null = null; + +interface DecodeWorker { + post(message: unknown, transfer: Transferable[]): void; + terminate(): void; +} + +interface Pending { + resolve(bitmap: Bitmap): void; + reject(error: unknown): void; + timer: ReturnType; +} + +interface WorkerState { + worker: DecodeWorker; + // Until the worker says it started, a failure means it cannot start. + ready: boolean; + pending: Map; + idle: ReturnType | null; + // Requests sent before the worker answered, in case it never does. + queued: Map; +} + +let current: WorkerState | null = null; +// False for good once a worker fails to start: decoding stays here. +let workersWork = true; +let nextRequest = 0; + +/** The module src/decode-worker.ts builds to, which decodes off-thread. */ +export function setDecodeWorkerURL(url: URL | null): void { + if (current !== null) { + endWorker(current, new Error("The image decoder was replaced")); + } + workerURL = url; + workersWork = url !== null; +} + +/** How long a decode may take on the worker before it is ended. */ +export function setDecodeTimeout(ms: number): void { + decodeTimeout = ms; +} + +/** + * Decode on a worker thread, where the runtime has one, so a large image + * neither stalls the page nor runs past decodeTimeout. Where no worker + * starts, the decode runs here, a slice at a time. + */ +export function decodeImageOffThread(bytes: Uint8Array): Promise { + const state = workersWork ? getWorker() : null; + if (state === null) { + return decodeImage(bytes); + } + const id = nextRequest++; + // A copy the worker can take, whatever buffer the bytes sit in. + const copy = bytes.slice(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + state.pending.delete(id); + reject( + new Error(`The image took more than ${decodeTimeout} ms to decode`), + ); + // What else this worker held fails with it: whatever stalled it + // may stall the next one too. + endWorker(state, new Error("The image decoder was stopped")); + }, decodeTimeout); + state.pending.set(id, {resolve, reject, timer}); + if (state.idle !== null) { + clearTimeout(state.idle); + state.idle = null; + } + if (!state.ready) { + state.queued.set(id, copy); + } + state.worker.post({id, bytes: copy.buffer}, state.ready + ? [copy.buffer] + : []); + }); +} + +function getWorker(): WorkerState | null { + if (current !== null) { + return current; + } + const url = workerURL; + if (url === null) { + return null; + } + let state: WorkerState | null = null; + const onMessage = (data: unknown) => { + if (state === null) { + return; + } + const message = data as { + ready?: boolean; + id?: number; + width?: number; + height?: number; + data?: ArrayBuffer; + error?: string; + }; + if (message.ready) { + state.ready = true; + state.queued.clear(); + return; + } + const pending = state.pending.get(message.id!); + if (pending === undefined) { + return; + } + state.pending.delete(message.id!); + clearTimeout(pending.timer); + if (message.error !== undefined) { + pending.reject(new Error(message.error)); + } else { + pending.resolve({ + width: message.width!, + height: message.height!, + data: new Uint8ClampedArray(message.data!), + }); + } + if (state.pending.size === 0) { + const idle = state; + state.idle = setTimeout(() => endWorker(idle, null), WORKER_IDLE_MS); + (state.idle as {unref?(): void}).unref?.(); + } + }; + const onError = (error: unknown) => { + if (state === null) { + return; + } + if (!state.ready) { + // It never started: decode here from now on, these included. + workersWork = false; + const queued = new Map(state.queued); + const pending = new Map(state.pending); + endWorker(state, null); + for (const [id, bytes] of queued) { + const request = pending.get(id)!; + clearTimeout(request.timer); + decodeImage(bytes).then(request.resolve, request.reject); + } + return; + } + endWorker( + state, + error instanceof Error ? error : new Error("The image decoder failed"), + ); + }; + const worker = startWorker(url, onMessage, onError); + if (worker === null) { + workersWork = false; + return null; + } + state = { + worker, + ready: false, + pending: new Map(), + idle: null, + queued: new Map(), + }; + current = state; + return state; +} + +// Ends the worker. What it still held fails with `error`, or is left +// to the caller when null. +function endWorker(state: WorkerState, error: Error | null): void { + if (current === state) { + current = null; + } + if (state.idle !== null) { + clearTimeout(state.idle); + } + state.worker.terminate(); + if (error !== null) { + for (const pending of state.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + } + state.pending.clear(); +} + +// A web worker where the runtime has one, Node's worker_threads where it +// has not. Either way, one that would keep the process alive is unref'd. +function startWorker( + url: URL, + onMessage: (data: unknown) => void, + onError: (error: unknown) => void, +): DecodeWorker | null { + try { + const Web = (globalThis as {Worker?: new ( + url: URL, + options: {type: "module"} + ) => { + postMessage(message: unknown, transfer: Transferable[]): void; + addEventListener(type: string, listener: (event: any) => void): void; + terminate(): void; + unref?(): void; + };}).Worker; + if (Web !== undefined) { + const worker = new Web(url, {type: "module"}); + worker.addEventListener("message", (event) => onMessage(event.data)); + worker.addEventListener("error", (event) => { + event.preventDefault?.(); + onError(event.error ?? event); + }); + worker.unref?.(); + return { + post: (message, transfer) => worker.postMessage(message, transfer), + terminate: () => worker.terminate(), + }; + } + const process = getProcess(); + if (process?.getBuiltinModule === undefined) { + return null; + } + const threads = process.getBuiltinModule("node:worker_threads") as { + Worker: new (url: URL) => { + postMessage(message: unknown, transfer: Transferable[]): void; + on(type: string, listener: (value: any) => void): void; + terminate(): Promise; + unref(): void; + }; + } | undefined; + if (threads === undefined) { + return null; + } + const worker = new threads.Worker(url); + worker.on("message", onMessage); + worker.on("error", onError); + worker.unref(); + return { + post: (message, transfer) => worker.postMessage(message, transfer), + terminate: () => { + worker.terminate().catch(() => {}); + }, + }; + } catch (_error) { + return null; + } +} diff --git a/tests/images.test.ts b/tests/images.test.ts index 37a198ed..8654cff7 100644 --- a/tests/images.test.ts +++ b/tests/images.test.ts @@ -13,12 +13,15 @@ import {expect, test} from "@b9g/libuild/test"; import { type Bitmap, decodeImage, + decodeImageOffThread, encodePNG, fetchImageBytes, MAX_IMAGE_BYTES, readDataURL, resolveImageURL, sampleBitmap, + setDecodeTimeout, + setDecodeWorkerURL, sniffImageType, } from "../src/internal/images.ts"; import {IMAGES} from "./fixtures/images.ts"; @@ -474,3 +477,53 @@ test("an image file past the byte limit is not read whole", async () => { globalThis.fetch = original; } }); + +// A worker from source text: it reports ready, then answers each +// request as `reply` says, or never. +function fakeWorker(reply: string): URL { + const source = + "const t = globalThis.process?.getBuiltinModule?.(\"node:worker_threads\");" + + "const port = t?.parentPort ?? globalThis;" + + "const answer = (data) => {" + + reply + + "};" + + "if (t?.parentPort) t.parentPort.on(\"message\", answer);" + + "else globalThis.addEventListener(\"message\", (e) => answer(e.data));" + + "port.postMessage({ready: true});"; + return new URL(`data:text/javascript,${encodeURIComponent(source)}`); +} + +test("a decode that runs past its time on the worker fails, and the next one starts over", async () => { + try { + setDecodeTimeout(50); + setDecodeWorkerURL(fakeWorker("")); + await expect(decodeImageOffThread(bytesOf("png-rgb.png"))) + .rejects.toThrow("more than 50 ms"); + setDecodeTimeout(10_000); + setDecodeWorkerURL( + fakeWorker("port.postMessage({id: data.id, error: \"from the worker\"})"), + ); + await expect(decodeImageOffThread(bytesOf("png-rgb.png"))) + .rejects.toThrow("from the worker"); + } finally { + setDecodeTimeout(10_000); + setDecodeWorkerURL(null); + } +}); + +test("where no worker starts, images decode on this thread", async () => { + try { + setDecodeWorkerURL( + new URL( + `data:text/javascript,${encodeURIComponent("throw new Error(\"no\");")}`, + ), + ); + const bitmap = await decodeImageOffThread(bytesOf("png-rgb.png")); + expect([bitmap.width, bitmap.height]).toEqual([16, 16]); + setDecodeWorkerURL(null); + const again = await decodeImageOffThread(bytesOf("png-rgb.png")); + expect(again.width).toBe(16); + } finally { + setDecodeWorkerURL(null); + } +}); From 65ebaccdb03a2129a1a7340337c30a2fe6096abb Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Sun, 4 Oct 2026 13:46:15 -0400 Subject: [PATCH 06/28] Draw an image that is not showing as a box with its alt text An image still loading, broken or blocked showed its alt text inline, or nothing while it loaded. It is now a bordered box with the alt text inside, cut with an ellipsis where it does not fit. The box takes the image's size when the page gives one and otherwise fits the text, three rows tall. One too small for a border, such as a 1 by 1 tracking image, holds the text alone. An empty alt still shows nothing, as decoration, and so does an with neither a src nor an alt. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- docs/guides/07-images-and-canvas.md | 9 ++++- src/internal/dom.ts | 11 +++--- src/internal/replaced.ts | 55 +++++++++++++++++++++++++++-- tests/img.test.ts | 47 ++++++++++++++++++++++-- 4 files changed, 113 insertions(+), 9 deletions(-) diff --git a/docs/guides/07-images-and-canvas.md b/docs/guides/07-images-and-canvas.md index b68b123c..896fa3a2 100644 --- a/docs/guides/07-images-and-canvas.md +++ b/docs/guides/07-images-and-canvas.md @@ -35,7 +35,14 @@ lossless JPEGs. `load` and `error` fire as they do in a browser, and `decode()`, `complete`, `naturalWidth`, `naturalHeight` and `currentSrc` report the image. `new Image()` makes an ``. `srcset` is read for its `1x` -candidate. An image that fails to load shows its `alt` text. +candidate. + +An image that is not showing, because it is still loading, failed to +load or was blocked, is a box with its `alt` text inside, cut with an +ellipsis when it does not fit. The box takes the image's size when the +page gives one, and otherwise fits the text. An empty `alt` marks an +image that is only decoration, and it shows nothing, as does an +`` with neither a `src` nor an `alt`. ### Size diff --git a/src/internal/dom.ts b/src/internal/dom.ts index 265fc8d8..624afb77 100644 --- a/src/internal/dom.ts +++ b/src/internal/dom.ts @@ -16567,12 +16567,15 @@ class HTMLImageElement extends HTMLElement { smooth: true, }; } - // Alt text stands in for an image that is not there. + // An image not showing, whether loading, broken or blocked, is a + // box with its alt text. An empty alt marks one that is only + // decoration, and with no source and no alt there is nothing to + // stand in for: both show nothing. const alt = this.getAttribute("alt"); - if (alt !== null && alt !== "" && state.status !== "loading") { - return {kind: "text", text: alt}; + if (alt === "" || (alt === null && state.status === "unavailable")) { + return null; } - return null; + return {kind: "text", text: alt ?? ""}; }); registerDrawable(this, () => { const state = this[kImageState]; diff --git a/src/internal/replaced.ts b/src/internal/replaced.ts index d37f8299..15ad040e 100644 --- a/src/internal/replaced.ts +++ b/src/internal/replaced.ts @@ -49,7 +49,9 @@ export function getNaturalSize( return {width: content.grid.cols, height: content.grid.rows}; } if (content.kind === "text") { - return {width: textWidth(content.text), height: content.text ? 1 : 0}; + // The alt text on one line inside a border, or an empty cell + // inside one when there is none. + return {width: Math.max(1, textWidth(content.text)) + 2, height: 3}; } const {width, height} = content.kind === "blank" ? content : content.bitmap; if (width === 0 || height === 0) { @@ -495,7 +497,7 @@ export function renderReplaced( }; try { if (content.kind === "text") { - ctx.drawText(content.text, left, top, style); + drawPlaceholder(content.text, left, top, cols, rows, ctx, style); } else if (content.kind === "grid") { ctx.drawGrid(getSettledGrid(content), left, top, style); } else if (cols * rows * 2 <= MAX_IMAGE_PIXELS) { @@ -525,6 +527,55 @@ export function renderReplaced( } } +/** + * An image that is not showing: a box around the content box with the + * alt text inside it, cut to fit. A box too small for a border holds + * the text alone. + */ +function drawPlaceholder( + text: string, + left: number, + top: number, + cols: number, + rows: number, + ctx: CellContext, + style: CellStyle, +): void { + if (cols < 3 || rows < 3) { + ctx.drawText(fitText(text, cols), left, top, style); + return; + } + const side = {style: "solid" as const, color: style.fg}; + ctx.drawBox(left, top, cols, rows, { + top: side, + right: side, + bottom: side, + left: side, + }); + ctx.drawText(fitText(text, cols - 2), left + 1, top + 1, style); +} + +// The text cut to `width` columns, with an ellipsis where it was cut. +function fitText(text: string, width: number): string { + if (textWidth(text) <= width) { + return text; + } + if (width <= 0) { + return ""; + } + let out = ""; + let used = 0; + for (const char of text) { + const columns = Math.max(0, getStringWidth(char)); + if (used + columns > width - 1) { + break; + } + out += char; + used += columns; + } + return out + "…"; +} + // A 2d context's text at the cells its anchors fall in, through the same // placement as the pixels. function paintTextRuns( diff --git a/tests/img.test.ts b/tests/img.test.ts index daa6fe0e..4d59f4f3 100644 --- a/tests/img.test.ts +++ b/tests/img.test.ts @@ -305,7 +305,7 @@ test("object-fit: contain centers the image and leaves the rest alone", async () dom.dispose(); }); -test("a broken image fires error, rejects decode() and shows its alt text", async () => { +test("a broken image fires error, rejects decode() and shows a box with its alt text", async () => { const {dom, terminal, document} = await mount("

"); const image = document.createElement("img"); image.alt = "cover art"; @@ -325,7 +325,9 @@ test("a broken image fires error, rejects decode() and shows its alt text", asyn }); expect((rejected as Error | null)?.name).toBe("EncodingError"); await nextFrame(dom); - expect(rowText(terminal, 0)).toBe("[cover art]"); + expect(rowText(terminal, 0)).toBe("[┌─────────┐]"); + expect(rowText(terminal, 1)).toBe(" │cover art│"); + expect(rowText(terminal, 2)).toBe(" └─────────┘"); dom.dispose(); }); @@ -661,3 +663,44 @@ test("an image whose box is past the pixel limit paints nothing", async () => { expect(cell(terminal, 0, 0).bg).toBe(-1); dom.dispose(); }); + +test("an image that is not showing is a box, sized as the image is", async () => { + const {dom, terminal, document} = await mount( + "
" + + "
\"\"|
" + + "
\"the
" + + "
\"logo\"
", + ); + await until(() => + [...document.querySelectorAll("img")].every((image) => image.complete), + ); + await nextFrame(dom); + expect([0, 1, 2].map((row) => rowText(terminal, row))).toEqual([ + "┌─┐", + "│ │", + "└─┘", + ]); + expect(rowText(terminal, 3)).toBe("|"); + expect([4, 5, 6, 7].map((row) => rowText(terminal, row))).toEqual([ + "┌────────┐", + "│the ban…│", + "│ │", + "└────────┘", + ]); + expect(rowText(terminal, 8)).toBe("lo…"); + dom.dispose(); +}); + +test("an image is a box while it loads", async () => { + const original = globalThis.fetch; + try { + globalThis.fetch = (() => new Promise(() => {})) as typeof fetch; + const {dom, terminal} = + await mount("\"a\""); + await nextFrame(dom); + expect(rowText(terminal, 1)).toBe("│a│"); + dom.dispose(); + } finally { + globalThis.fetch = original; + } +}); From d42b50cc8b116933db096d55a78d35c6cb0b031b Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Sun, 4 Oct 2026 14:24:26 -0400 Subject: [PATCH 07/28] Load what markup asks for through "request" events and a fetch TermDOM fetched every image source itself: http(s) through the runtime's fetch, file: from disk and data: with its own parser. A page showing untrusted HTML could not stop a tracking image or a read of a local file, and an email's cid: references had no way to load. Loading is now the program's. TermDOM is an EventTarget, and each load a document's markup asks for is a plain Request, dispatched as a "request" event. The RequestEvent carries what the Request cannot: destination (the Fetch standard's "image"), initiatorType (Resource Timing's "img") and initiator, the element that asked. A listener answers with respondWith(), as a Service Worker answers a fetch event, and the first answer wins; Response.error() or a rejection fails the request. One no listener answers goes to the `fetch` option, and without one it fails, so nothing loads unless the program says so. As in a browser, a document that is not a file: or about: URL loads no file: URL. The page's own window.fetch is the `fetch` given, or the runtime's. A load waits a microtask, as HTML's "update the image data" does, so a listener added just after the constructor's markup hears its images. Those images never loaded before: the parser built them before the document had a window, and only insertion started them. The window now starts them. images.ts no longer reads files or data: URLs; the response a load returns is read under the 64 MB limit. The images example answers file: URLs itself, which Node's fetch does not. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- docs/guides/07-images-and-canvas.md | 51 +++++++- examples/images.ts | 14 ++- scripts/support-matrix.ts | 5 +- src/index.ts | 23 +++- src/internal/dom.ts | 57 +++++++-- src/internal/images.ts | 95 ++------------- src/internal/resources.ts | 156 ++++++++++++++++++++++++ tests/canvas.test.ts | 5 +- tests/images.test.ts | 66 +++-------- tests/img.test.ts | 66 ++++++----- tests/requests.test.ts | 176 ++++++++++++++++++++++++++++ 11 files changed, 536 insertions(+), 178 deletions(-) create mode 100644 src/internal/resources.ts create mode 100644 tests/requests.test.ts diff --git a/docs/guides/07-images-and-canvas.md b/docs/guides/07-images-and-canvas.md index 896fa3a2..4df5ac2c 100644 --- a/docs/guides/07-images-and-canvas.md +++ b/docs/guides/07-images-and-canvas.md @@ -14,11 +14,50 @@ redraw like text, and they work over SSH and inside tmux. Album cover ``` -`src` can be a `data:` URL, a `file:` URL, a path, or an `http:` or -`https:` URL. A path is relative to the document's URL. A document at -`about:blank` has no URL to resolve against, so a path there is -relative to the working directory. `http:` and `https:` go through -`fetch()`. +`src` is a URL: `https:`, `data:`, `file:` or any other. A path is +relative to the document's URL. A document at `about:blank` has no URL +to resolve against, so a path there is a `file:` URL under the working +directory. + +### Loading + +Nothing loads unless the program says so. Each load the markup asks for +is a plain `Request`, which the TermDOM dispatches as a `"request"` +event. A listener answers it with `respondWith()`, as a Service Worker +answers a fetch event, and the first answer wins. A request no listener +answers goes to the `fetch` the TermDOM was made with. Without one, it +fails, and the image shows as a box with its `alt` text. + +```ts +// Load what a browser would: the runtime's fetch. +const term = new TermDOM({fetch}); + +// Or decide request by request. +term.addEventListener("request", (event) => { + const {request, destination, initiatorType, initiator} = event; + if (request.url.startsWith("cid:")) { + event.respondWith(new Response(attachment(request.url))); + } else if (!remoteImagesAllowed(initiator)) { + event.respondWith(Response.error()); + } +}); +``` + +The event carries what the request does not: `destination`, the Fetch +standard's name for what the load is for (`"image"`), `initiatorType`, +Resource Timing's name for what asked (`"img"`), and `initiator`, the +element that asked. `Response.error()`, or a promise that rejects, +fails the request. + +The request waits for the script that set the source to finish, so a +listener added just after the markup sees its images. A document whose +URL is not `file:` or `about:` loads no `file:` URL, whatever would +answer it. Node's `fetch` reads no files, and Bun's and Deno's do, so a +program that wants local files on every runtime answers them itself. + +The page's own `window.fetch` is the `fetch` given, or the runtime's +when there is none. It dispatches no event: a script that calls it has +asked for the load. PNG, JPEG, GIF and BMP decode: @@ -82,7 +121,7 @@ An image file names its size before its pixels, so a few bytes can ask for gigabytes. As a browser does, the engine checks what a file asks for before it allocates, and an image past a limit fails to load: -- a file of at most 64 MB, whether `data:`, `file:` or fetched +- a response of at most 64 MB, by its declared length and while reading - at most 32768 pixels to a side and 2^26 pixels in all - a PNG's data inflated only as far as its image needs - a JPEG of at most four components and 500 scans diff --git a/examples/images.ts b/examples/images.ts index 3b24df56..29fa4313 100644 --- a/examples/images.ts +++ b/examples/images.ts @@ -1,3 +1,5 @@ +import {readFile} from "node:fs/promises"; + import {TermDOM} from "@b9g/termdom"; // node examples/images.ts [path-or-url] @@ -6,7 +8,17 @@ import {TermDOM} from "@b9g/termdom"; // to as a data: URL, so the example runs anywhere. const source = typeof process === "undefined" ? undefined : process.argv[2]; -const term = new TermDOM(); +// Images load only through the fetch a TermDOM is given. This one reads +// a file: URL from disk, which Node's own fetch does not, and leaves the +// rest to the runtime. +const term = new TermDOM({ + async fetch(request) { + if (request.url.startsWith("file:")) { + return new Response(await readFile(new URL(request.url))); + } + return fetch(request); + }, +}); term.attach(); const {document} = term; diff --git a/scripts/support-matrix.ts b/scripts/support-matrix.ts index b4bb385c..6e63e670 100644 --- a/scripts/support-matrix.ts +++ b/scripts/support-matrix.ts @@ -1065,7 +1065,10 @@ function apiProbe( category, async run() { const terminal = new MockProcess({cols: 40, rows: 12}); - const dom = new TermDOM({transport: terminal.transport}); + const dom = new TermDOM({ + transport: terminal.transport, + fetch: (request) => fetch(request), + }); try { return {supported: await run(dom), note}; } catch (_err) { diff --git a/src/index.ts b/src/index.ts index 6c76a831..50ba8335 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,9 +48,11 @@ import {setDecodeWorkerURL} from "./internal/images.ts"; import {Input} from "./internal/input.ts"; import {Layout} from "./internal/layout.ts"; import {Painter} from "./internal/painter.ts"; +import {answerRequest, setResourceLoader} from "./internal/resources.ts"; export type {CellSize} from "./internal/cssom.ts"; export {transportFromProcess} from "./internal/exchange.ts"; +export {RequestEvent, type RequestEventInit} from "./internal/resources.ts"; export type { ProcessLike, TTYReadStream, @@ -95,6 +97,14 @@ export interface TermDOMOptions { * - `{width, height}`: that size. */ cellSize?: "unit" | "auto" | CellSize; + + /** + * Where the loads the document's markup asks for go, an 's source + * among them, when no "request" listener answers them, and what the + * page's own `window.fetch` calls. Without one, markup loads nothing, + * and `window.fetch` is the runtime's. + */ + fetch?: (request: Request) => Promise; } function getCellSizeSource( @@ -204,11 +214,16 @@ export interface TermDOM { [kStaticSibling]: TermDOM | null; } -export class TermDOM { +/** + * A document drawn in a terminal. It is the target of a "request" event + * for each load its markup asks for (see RequestEvent). + */ +export class TermDOM extends EventTarget { readonly document: Document; readonly window: Window; constructor(options: TermDOMOptions = {}) { + super(); const screenCell = () => this[kTransport].interactive ? this[kFramebuffer].cellPixels @@ -241,6 +256,12 @@ export class TermDOM { ); const document = this.document = this.window.document; + if (options.fetch !== undefined) { + this.window.fetch = options.fetch as typeof this.window.fetch; + } + setResourceLoader(document, (request, context) => + answerRequest(this, document.URL, request, context, options.fetch), + ); this[kLayout] = new Layout( this.window, diff --git a/src/internal/dom.ts b/src/internal/dom.ts index 624afb77..0d82702c 100644 --- a/src/internal/dom.ts +++ b/src/internal/dom.ts @@ -61,13 +61,15 @@ import { type Bitmap, createBitmap, decodeImageOffThread, - fetchImageBytes, + IMAGE_ACCEPT, MAX_IMAGE_PIXELS, + readImageResponse, registerReplacedContent, resolveImageURL, } from "./images.ts"; import type {Layout} from "./layout.ts"; import {linearizeMath} from "./mathml.ts"; +import {loadResource} from "./resources.ts"; import { getNextGraphemeBoundary, getPreviousGraphemeBoundary, @@ -16796,12 +16798,36 @@ function updateImageData(image: HTMLImageElement): void { state.status = "loading"; invalidateReplaced(image, false); const abort = state.abort = new AbortController(); - fetchImageBytes(url, abort.signal) - .then((bytes) => decodeImageOffThread(bytes)) - .then( - (bitmap) => settle("complete", bitmap), - (error) => settle("broken", null, error), - ); + const document = image.ownerDocument; + // As HTML's "update the image data" does, the request waits for the + // script that set the source to finish. A listener added just after + // the markup that holds the image sees it. + queueMicrotask(() => { + if (state.generation !== generation) { + return; + } + let request: Request; + try { + request = new Request(url, { + headers: {accept: IMAGE_ACCEPT}, + signal: abort.signal, + }); + } catch (error) { + settle("broken", null, error); + return; + } + loadResource(document, request, { + destination: "image", + initiatorType: "img", + initiator: image as unknown as globalThis.Element, + }) + .then((response) => readImageResponse(response)) + .then((bytes) => decodeImageOffThread(bytes)) + .then( + (bitmap) => settle("complete", bitmap), + (error) => settle("broken", null, error), + ); + }); } const kDirtyValue = Symbol("dirtyValue"); @@ -35881,9 +35907,26 @@ function buildWindow(document: Document): Window { }); window.window = window; window.self = window; + startPendingImages(document as unknown as globalThis.Document); return window as unknown as Window; } +// Images the parser built before their document had a window wait for +// one. The document has one now. +function startPendingImages( + root: globalThis.Document | globalThis.ShadowRoot, +): void { + for (const element of root.querySelectorAll("*")) { + if (element instanceof HTMLImageElement && element[kImageState].pending) { + updateImageData(element); + } + const shadow = getShadowRoot(element); + if (shadow !== null) { + startPendingImages(shadow); + } + } +} + /** * Parse a document from markup and display it in a window of its own. An * XML content type parses as XML, which is what a frame whose src names a diff --git a/src/internal/images.ts b/src/internal/images.ts index 99bf8378..424efaa5 100644 --- a/src/internal/images.ts +++ b/src/internal/images.ts @@ -1997,40 +1997,7 @@ export function isReplacedElement(owner: object): boolean { } // --------------------------------------------------------------------------- -// Fetching what a src names. - -/** The bytes of a data: URL, or null when it is not one. */ -export function readDataURL(url: string): Uint8Array | null { - const match = /^data:([^,]*?)(;base64)?,(.*)$/is.exec(url); - if (match === null) { - return null; - } - const body = percentDecode(match[3]); - if (!match[2]) { - return body; - } - const binary = atob( - new TextDecoder("latin1").decode(body).replace(/\s+/g, ""), - ); - return Uint8Array.from(binary, (char) => char.charCodeAt(0)); -} - -// URL's percent-decode: each %XX is that byte, and the rest is UTF-8. -function percentDecode(text: string): Uint8Array { - const input = new TextEncoder().encode(text); - const out = new Uint8Array(input.length); - let length = 0; - for (let i = 0; i < input.length; i++) { - const hex = String.fromCharCode(input[i + 1] ?? 0, input[i + 2] ?? 0); - if (input[i] === 0x25 && /^[0-9a-f]{2}$/i.test(hex)) { - out[length++] = parseInt(hex, 16); - i += 2; - } else { - out[length++] = input[i]; - } - } - return out.subarray(0, length); -} +// Reading what a src names. interface ProcessWithBuiltins { cwd?: () => string; @@ -2063,32 +2030,6 @@ export function resolveImageURL(src: string, base: string): string | null { return URL.canParse(src, root) ? new URL(src, root).href : null; } -interface FileSystem { - readFile(path: URL, options?: {signal?: AbortSignal}): Promise; - stat(path: URL): Promise<{size: number}>; -} - -// Node's file system, which Bun and Deno provide too. getBuiltinModule -// is synchronous and needs no import a bundler would follow; Node before -// 20.16 lacks it and gets the dynamic import, whose specifier is built at -// run time for the same reason. -async function getFileSystem(): Promise { - const process = getProcess(); - if (process === undefined) { - return undefined; - } - if (process.getBuiltinModule !== undefined) { - return process.getBuiltinModule("node:fs/promises") as FileSystem | - undefined; - } - const specifier = ["node:fs", "promises"].join("/"); - try { - return await import(specifier) as FileSystem; - } catch (_error) { - return undefined; - } -} - /** The most bytes an image file may have before it decodes. */ export const MAX_IMAGE_BYTES = 1 << 26; @@ -2099,33 +2040,23 @@ function checkImageBytes(length: number): void { } /** - * The bytes a URL names, up to MAX_IMAGE_BYTES. data: is read here. - * file: goes through the runtime's file system where it has one (Node's - * fetch does not read files), and anything else through fetch, which - * `signal` cancels. + * The image types the decoders read, for a request's Accept header, so a + * server that picks a format by it picks one of these. + */ +export const IMAGE_ACCEPT = + "image/png,image/jpeg,image/gif,image/bmp;q=0.9,*/*;q=0.5"; + +/** + * A response's bytes, up to MAX_IMAGE_BYTES: by its declared length when + * it has one, and while reading when it has not. */ -export async function fetchImageBytes( - url: string, - signal?: AbortSignal, +export async function readImageResponse( + response: Response, ): Promise { - const inline = readDataURL(url); - if (inline !== null) { - checkImageBytes(inline.length); - return inline; - } - if (url.startsWith("file:")) { - const fs = await getFileSystem(); - if (fs !== undefined) { - const path = new URL(url); - checkImageBytes((await fs.stat(path)).size); - return new Uint8Array(await fs.readFile(path, {signal})); - } - } - const response = await fetch(url, {signal}); if (!response.ok) { throw new Error(`The image request failed with status ${response.status}`); } - const declared = Number(response.headers.get("content-length")); + const declared = Number(response.headers.get("content-length") ?? NaN); if (Number.isFinite(declared)) { checkImageBytes(declared); } diff --git a/src/internal/resources.ts b/src/internal/resources.ts new file mode 100644 index 00000000..abcbc3d3 --- /dev/null +++ b/src/internal/resources.ts @@ -0,0 +1,156 @@ +/** + * Loading what a document's markup asks for: an 's source now, + * stylesheets and the rest later. Each load is a plain Request, which + * a TermDOM hands to its "request" listeners as a RequestEvent, with + * what asked for it. A listener answers with respondWith(), as a + * Service Worker answers a fetch event. A request no listener answers + * goes to the `fetch` the TermDOM was made with, and without one it + * fails: nothing loads unless the program says so. + */ + +/** What a RequestEvent carries besides the request itself. */ +export interface RequestContext { + // The Fetch standard's request destination: "image" for an . + destination: RequestDestination; + // Resource Timing's initiator type: "img" for an . + initiatorType: string; + // The element that asked, or null. + initiator: Element | null; +} + +export interface RequestEventInit extends EventInit, Partial { + request: Request; +} + +const kResponse = Symbol("response"); +const kDispatching = Symbol("dispatching"); +const kExtensions = Symbol("extensions"); + +export interface RequestEvent { + [kResponse]: Promise | null; + [kDispatching]: boolean; + [kExtensions]: Array>; +} + +/** + * A load a document's markup asks for. A listener that answers it calls + * respondWith() before it returns, and the first answer wins. + */ +export class RequestEvent extends Event { + readonly request: Request; + readonly destination: RequestDestination; + readonly initiatorType: string; + readonly initiator: Element | null; + + constructor(type: string, init: RequestEventInit) { + super(type, init); + this.request = init.request; + this.destination = init.destination ?? ""; + this.initiatorType = init.initiatorType ?? "other"; + this.initiator = init.initiator ?? null; + this[kResponse] = null; + this[kDispatching] = false; + this[kExtensions] = []; + } + + /** + * Answer the request, during the event. Response.error(), or a + * promise that rejects, fails it as a network error does. + */ + respondWith(response: Response | PromiseLike): void { + if (!this[kDispatching]) { + throw new DOMException( + "respondWith() is only called while the event is dispatched", + "InvalidStateError", + ); + } + if (this[kResponse] !== null) { + throw new DOMException( + "The request was already answered", + "InvalidStateError", + ); + } + this[kResponse] = Promise.resolve(response); + this.stopImmediatePropagation(); + } + + /** Work the listener goes on with after it answers. */ + waitUntil(promise: PromiseLike): void { + this[kExtensions].push(Promise.resolve(promise)); + } +} + +/** How a document's loads are answered, set by the TermDOM that owns it. */ +export type ResourceLoader = ( + request: Request, + context: RequestContext, +) => Promise; + +const loaders = new WeakMap(); + +export function setResourceLoader( + document: object, + loader: ResourceLoader, +): void { + loaders.set(document, loader); +} + +/** + * The response to a load the document's markup asks for. A document no + * TermDOM owns, such as DOMParser's, loads nothing. + */ +export function loadResource( + document: object, + request: Request, + context: RequestContext, +): Promise { + const loader = loaders.get(document); + if (loader === undefined) { + return Promise.reject(new TypeError("This document loads nothing")); + } + return loader(request, context); +} + +/** + * Dispatch the request to `target`'s listeners and settle it: their + * answer, else `fallback`, else a network error. As in a browser, a + * document that is not a file loads no file. + */ +export function answerRequest( + target: EventTarget, + documentURL: string, + request: Request, + context: RequestContext, + fallback: ((request: Request) => Promise) | undefined, +): Promise { + if ( + new URL(request.url).protocol === "file:" && + !/^(?:file|about):/.test(documentURL) + ) { + return Promise.reject( + new TypeError("A document that is not a file cannot load a file"), + ); + } + const event = new RequestEvent("request", {request, ...context}); + event[kDispatching] = true; + try { + target.dispatchEvent(event); + } finally { + event[kDispatching] = false; + } + const answer = event[kResponse]; + if (answer !== null) { + return answer.then((response) => { + if (response.type === "error") { + throw new TypeError("The request was refused"); + } + return response; + }); + } + if (fallback !== undefined) { + return fallback(request); + } + return Promise.reject( + new TypeError("Nothing answered the request, and no fetch was given"), + ); +} diff --git a/tests/canvas.test.ts b/tests/canvas.test.ts index f6cdb712..8fb3b52c 100644 --- a/tests/canvas.test.ts +++ b/tests/canvas.test.ts @@ -50,7 +50,10 @@ async function mount( rows = 12, ): Promise<{dom: TermDOM; terminal: MockProcess; document: Document}> { const terminal = new MockProcess({cols, rows}); - const dom = new TermDOM({transport: terminal.transport}); + const dom = new TermDOM({ + transport: terminal.transport, + fetch: (request) => fetch(request), + }); dom.document.body.innerHTML = html; await nextFrame(dom); return {dom, terminal, document: dom.document}; diff --git a/tests/images.test.ts b/tests/images.test.ts index 8654cff7..fed54e0d 100644 --- a/tests/images.test.ts +++ b/tests/images.test.ts @@ -15,9 +15,8 @@ import { decodeImage, decodeImageOffThread, encodePNG, - fetchImageBytes, MAX_IMAGE_BYTES, - readDataURL, + readImageResponse, resolveImageURL, sampleBitmap, setDecodeTimeout, @@ -295,28 +294,12 @@ test("sampling averages the pixels a cell half covers, weighted by alpha", () => .toEqual([0, 0, 255, 255]); }); -test("data: URLs decode percent-encoded bytes, binary ones included", () => { - const bytes = readDataURL("data:application/octet-stream,%89PNG%0d%0a"); - expect(Array.from(bytes!)).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); - expect(Array.from(readDataURL("data:;base64,AQ%3D%3D")!)).toEqual([1]); -}); - test("a Windows path is a file URL, not a URL with a drive-letter scheme", () => { expect(resolveImageURL("C:\\art\\cover.png", "about:blank")).toBe( "file:///C:/art/cover.png", ); }); -test("data: URLs decode base64 and percent-encoding", () => { - expect(Array.from(readDataURL("data:image/png;base64,AQID")!)).toEqual([ - 1, - 2, - 3, - ]); - expect(new TextDecoder().decode(readDataURL("data:,a%20b")!)).toBe("a b"); - expect(readDataURL("https://example.com/a.png")).toBeNull(); -}); - test("a relative src resolves against the document, or the working directory", () => { expect(resolveImageURL("b.png", "https://example.com/a/")).toBe( "https://example.com/a/b.png", @@ -444,38 +427,25 @@ test("PNG data that inflates past its image is read only as far as the image", a expect(pixel(bitmap, 15, 15)).toEqual([240, 240, 240, 255]); }); -test("an image file past the byte limit is not read whole", async () => { - const original = globalThis.fetch; - const signals: Array = []; +test("an image response past the byte limit is not read whole", async () => { const piece = new Uint8Array(1 << 20); + const declared = new Response(piece, { + headers: {"content-length": String(MAX_IMAGE_BYTES + 1)}, + }); + await expect(readImageResponse(declared)).rejects.toThrow("more than"); let pulled = 0; - try { - globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { - signals.push(init?.signal ?? undefined); - if (signals.length === 1) { - return new Response(piece, { - headers: {"content-length": String(MAX_IMAGE_BYTES + 1)}, - }); - } - return new Response( - new ReadableStream({ - pull(controller) { - pulled++; - controller.enqueue(piece); - }, - }), - ); - }) as typeof fetch; - const abort = new AbortController(); - await expect(fetchImageBytes("https://example.com/a.png", abort.signal)) - .rejects.toThrow("more than"); - expect(signals[0]).toBe(abort.signal); - await expect(fetchImageBytes("https://example.com/b.png")) - .rejects.toThrow("more than"); - expect(pulled).toBeLessThanOrEqual(MAX_IMAGE_BYTES / piece.length + 2); - } finally { - globalThis.fetch = original; - } + const endless = new Response( + new ReadableStream({ + pull(controller) { + pulled++; + controller.enqueue(piece); + }, + }), + ); + await expect(readImageResponse(endless)).rejects.toThrow("more than"); + expect(pulled).toBeLessThanOrEqual(MAX_IMAGE_BYTES / piece.length + 2); + await expect(readImageResponse(new Response(null, {status: 404}))) + .rejects.toThrow("status 404"); }); // A worker from source text: it reports ready, then answers each diff --git a/tests/img.test.ts b/tests/img.test.ts index 4d59f4f3..87dc1d31 100644 --- a/tests/img.test.ts +++ b/tests/img.test.ts @@ -14,7 +14,7 @@ import {expect, test} from "@b9g/libuild/test"; -import {TermDOM} from "../src/index.ts"; +import {type RequestEvent, TermDOM} from "../src/index.ts"; import {encodePNG} from "../src/internal/images.ts"; import {MockProcess, nextFrame, scriptReplies, until} from "./test-utils.ts"; @@ -93,7 +93,10 @@ async function mount( {ask: "\x1b[16t", reply: `\x1b[6;${height};${width}t`}, ]); } - const dom = new TermDOM({transport: terminal.transport}); + const dom = new TermDOM({ + transport: terminal.transport, + fetch: (request) => fetch(request), + }); dom.document.body.innerHTML = html; await nextFrame(dom); return {dom, terminal, document: dom.document}; @@ -581,7 +584,8 @@ test("decode() settles when src changes, and the current image stays up meanwhil release = resolve; }); globalThis.fetch = (async (input: RequestInfo | URL) => { - if (String(input) === "https://images.test/bands.png") { + const url = input instanceof Request ? input.url : String(input); + if (url === "https://images.test/bands.png") { await held; return new Response(bands); } @@ -610,6 +614,7 @@ test("with a cell size, an image's pixels and lengths are CSS pixels", async () const dom = new TermDOM({ transport: terminal.transport, cellSize: {width: 7, height: 15}, + fetch: (request) => fetch(request), }); dom.document.body.innerHTML = `` + @@ -632,25 +637,24 @@ test("with a cell size, an image's pixels and lengths are CSS pixels", async () }); test("a new src cancels the request the old one started", async () => { - const original = globalThis.fetch; + const {dom, document} = await mount(""); const signals: AbortSignal[] = []; - try { - globalThis.fetch = ((_url: unknown, init?: RequestInit) => { - const signal = init!.signal!; - signals.push(signal); - return new Promise((_resolve, reject) => { + dom.addEventListener("request", (event) => { + const {signal} = (event as RequestEvent).request; + signals.push(signal); + (event as RequestEvent).respondWith( + new Promise((_resolve, reject) => { signal.addEventListener("abort", () => reject(signal.reason)); - }); - }) as typeof fetch; - const {dom, document} = await mount(""); - const image = document.getElementById("i") as HTMLImageElement; - image.src = "https://example.com/first.png"; - image.src = "https://example.com/second.png"; - expect(signals.map((signal) => signal.aborted)).toEqual([true, false]); - dom.dispose(); - } finally { - globalThis.fetch = original; - } + }), + ); + }); + const image = document.getElementById("i") as HTMLImageElement; + image.src = "https://example.com/first.png"; + await until(() => signals.length === 1); + image.src = "https://example.com/second.png"; + await until(() => signals.length === 2); + expect(signals.map((signal) => signal.aborted)).toEqual([true, false]); + dom.dispose(); }); test("an image whose box is past the pixel limit paints nothing", async () => { @@ -692,15 +696,15 @@ test("an image that is not showing is a box, sized as the image is", async () => }); test("an image is a box while it loads", async () => { - const original = globalThis.fetch; - try { - globalThis.fetch = (() => new Promise(() => {})) as typeof fetch; - const {dom, terminal} = - await mount("\"a\""); - await nextFrame(dom); - expect(rowText(terminal, 1)).toBe("│a│"); - dom.dispose(); - } finally { - globalThis.fetch = original; - } + const terminal = new MockProcess({cols: 40, rows: 12}); + const dom = new TermDOM({transport: terminal.transport}); + dom.addEventListener("request", (event) => { + (event as RequestEvent).respondWith(new Promise(() => {})); + }); + dom.document.body.innerHTML = + "\"a\""; + await nextFrame(dom); + await nextFrame(dom); + expect(rowText(terminal, 1)).toBe("│a│"); + dom.dispose(); }); diff --git a/tests/requests.test.ts b/tests/requests.test.ts new file mode 100644 index 00000000..1b7d94f7 --- /dev/null +++ b/tests/requests.test.ts @@ -0,0 +1,176 @@ +/** + * What a document's markup loads goes to the TermDOM as a "request" + * event, then to the `fetch` it was given, and otherwise nowhere. + */ +import {expect, test} from "@b9g/libuild/test"; + +import {RequestEvent, TermDOM} from "../src/index.ts"; +import {encodePNG} from "../src/internal/images.ts"; +import {MockProcess, until} from "./test-utils.ts"; + +const PNG = encodePNG({ + width: 2, + height: 2, + data: new Uint8ClampedArray(16).fill(255), +}) as Uint8Array; + +function create( + options: { + html?: string; + url?: string; + fetch?: (request: Request) => Promise; + } = {}, +): TermDOM { + return new TermDOM({ + transport: new MockProcess({cols: 40, rows: 12}).transport, + ...options, + }); +} + +async function settled(image: HTMLImageElement): Promise { + await until(() => image.complete); + return image.naturalWidth > 0 ? "loaded" : "broken"; +} + +test("without a listener or a fetch, markup loads nothing", async () => { + const dom = create({ + html: "\"logo\"", + }); + const image = dom.document.querySelector("img")!; + let failed = false; + image.addEventListener("error", () => { + failed = true; + }); + expect(await settled(image)).toBe("broken"); + await until(() => failed); + dom.dispose(); +}); + +test("a listener answers what markup asks for, and says what asked", async () => { + const dom = create(); + const seen: Array> = []; + dom.addEventListener("request", (event) => { + const request = event as RequestEvent; + seen.push(request); + if (request.request.url.startsWith("cid:")) { + request.respondWith(new Response(PNG)); + } + }); + dom.document.body.innerHTML = ""; + const image = dom.document.getElementById("a") as HTMLImageElement; + expect(await settled(image)).toBe("loaded"); + expect(seen).toHaveLength(1); + expect(seen[0].request!.url).toBe("cid:logo@example.com"); + expect(seen[0].request!.headers.get("accept")).toContain("image/png"); + expect(seen[0].destination).toBe("image"); + expect(seen[0].initiatorType).toBe("img"); + expect(seen[0].initiator).toBe(image); + dom.dispose(); +}); + +test("a request no listener answers goes to the fetch given", async () => { + const asked: string[] = []; + const dom = create({ + fetch: async (request) => { + asked.push(request.url); + return new Response(PNG); + }, + }); + dom.document.body.innerHTML = ""; + expect(await settled(dom.document.querySelector("img")!)).toBe("loaded"); + expect(asked).toEqual(["https://example.com/a.png"]); + dom.dispose(); +}); + +test("Response.error() or a rejection refuses the request", async () => { + const dom = create({fetch: async () => new Response(PNG)}); + dom.addEventListener("request", (event) => { + const request = event as RequestEvent; + request.respondWith( + request.request.url.endsWith("a.png") + ? Response.error() + : Promise.reject(new Error("no")), + ); + }); + dom.document.body.innerHTML = + "" + + ""; + const a = dom.document.getElementById("a") as HTMLImageElement; + const b = dom.document.getElementById("b") as HTMLImageElement; + expect([await settled(a), await settled(b)]).toEqual(["broken", "broken"]); + dom.dispose(); +}); + +test("respondWith() is called once, during the event", async () => { + const dom = create(); + let late: RequestEvent | null = null; + const errors: string[] = []; + dom.addEventListener("request", (event) => { + const request = event as RequestEvent; + late = request; + request.respondWith(new Response(PNG)); + try { + request.respondWith(new Response(PNG)); + } catch (error) { + errors.push((error as DOMException).name); + } + }); + dom.addEventListener("request", () => { + errors.push("second listener ran"); + }); + dom.document.body.innerHTML = ""; + expect(await settled(dom.document.querySelector("img")!)).toBe("loaded"); + expect(() => late!.respondWith(new Response(PNG))).toThrow(); + expect(errors).toEqual(["InvalidStateError"]); + dom.dispose(); +}); + +test("a listener added after the constructor's markup hears its images", async () => { + const dom = create({html: ""}); + dom.addEventListener("request", (event) => { + (event as RequestEvent).respondWith(new Response(PNG)); + }); + expect(await settled(dom.document.querySelector("img")!)).toBe("loaded"); + dom.dispose(); +}); + +test("a document that is not a file loads no file, whatever answers", async () => { + let heard = 0; + const dom = create({ + url: "https://mail.example/message/1", + fetch: async () => new Response(PNG), + }); + dom.addEventListener("request", () => { + heard++; + }); + dom.document.body.innerHTML = ""; + expect(await settled(dom.document.querySelector("img")!)).toBe("broken"); + expect(heard).toBe(0); + dom.dispose(); + + const local = create({fetch: async () => new Response(PNG)}); + local.document.body.innerHTML = ""; + expect(await settled(local.document.querySelector("img")!)).toBe("loaded"); + local.dispose(); +}); + +test("window.fetch is the fetch given, and the runtime's without one", async () => { + const given = async () => new Response("given"); + const dom = create({fetch: given}); + expect(dom.window.fetch).toBe(given); + dom.dispose(); + + const plain = create(); + const response = await plain.window.fetch("data:text/plain,platform"); + expect(await response.text()).toBe("platform"); + plain.dispose(); +}); + +test("a RequestEvent can be made and dispatched by hand", () => { + const request = new Request("https://example.com/"); + const event = new RequestEvent("request", {request, destination: "image"}); + expect(event.destination).toBe("image"); + expect(event.initiatorType).toBe("other"); + expect(event.initiator).toBeNull(); + expect(() => event.respondWith(new Response(""))).toThrow(); +}); From 9ff2772a5d181eed2d44742951183ef08fc788b5 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Sun, 4 Oct 2026 14:25:25 -0400 Subject: [PATCH 08/28] Drop the brackets from the images example's alt text An image that is not showing is a box now, which sets its alt text off on its own. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- examples/images.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/images.ts b/examples/images.ts index 29fa4313..851c0dd1 100644 --- a/examples/images.ts +++ b/examples/images.ts @@ -81,7 +81,7 @@ document.body.innerHTML = ` -

A broken image shows its alt text: [no cover art]

+

A broken image is a box with its alt text: no cover art

`; From 5b8e69a4aa5b414511897428baca3be4356bb132 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Sun, 4 Oct 2026 16:17:53 -0400 Subject: [PATCH 09/28] Draw two pixels as a lower half block over a background A terminal that draws block characters from the font shows the font's glyph, and Terminal.app does. Menlo's and SF Mono's half blocks sit low in the cell: the upper one covers from 14% to 57% of the cell's height, leaving the cell's top in the background color and running past the middle, so a two-color cell drawn with it was off in two stripes and a gradient banded at every row. With the lower half block the top pixel is the background, which fills whatever the glyph leaves, so only the seam between the two pixels sits a little low. Terminals that draw the blocks themselves (kitty, Ghostty, WezTerm, Alacritty, foot, VTE, Windows Terminal and others) show either exactly. A cell where only the upper pixel shows keeps the upper half block, since the color under it is the page's. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- docs/guides/07-images-and-canvas.md | 24 ++++++++++++++++-------- src/internal/canvas.ts | 25 ++++++++++++++++++------- src/internal/images.ts | 4 ++-- tests/canvas.test.ts | 24 ++++++++++++------------ tests/img.test.ts | 14 +++++++------- 5 files changed, 55 insertions(+), 36 deletions(-) diff --git a/docs/guides/07-images-and-canvas.md b/docs/guides/07-images-and-canvas.md index 4df5ac2c..56a78706 100644 --- a/docs/guides/07-images-and-canvas.md +++ b/docs/guides/07-images-and-canvas.md @@ -3,9 +3,17 @@ title: Images and canvas description: How and draw pixels in cells. --- -A cell can show two pixels: the upper half block, `▀`, in the -foreground color over the background color. `` and `` -draw that way. The pixels are ordinary cells, so they scroll, clip and +A cell can show two pixels: the lower half block, `▄`, in the +foreground color under the background color. `` and `` +draw that way. + +Most terminals draw block characters themselves, so either half fills +its half of the cell exactly. Terminal.app draws them from the font, +and Menlo's and SF Mono's sit low in the cell: the upper half block +leaves a sliver of the wrong color at the top and runs past the +middle. The background fills whatever the glyph leaves, so with the +lower half block only the seam between the two pixels is a little +low. The pixels are ordinary cells, so they scroll, clip and redraw like text, and they work over SSH and inside tmux. ## `` @@ -229,13 +237,13 @@ not the page's borders around the canvas. ## Text alone -Pixels can also be written as text: an upper half block in a span -whose `color` is the upper pixel and whose `background-color` is the -lower one. +Pixels can also be written as text: a lower half block in a span +whose `color` is the lower pixel and whose `background-color` is the +upper one. ```html -
▀▀▀
-▀▀▀
+
▄▄▄
+▄▄▄
``` That stays aligned through scrolling, clipping and repaints, as any diff --git a/src/internal/canvas.ts b/src/internal/canvas.ts index 7b1757c1..30cca78f 100644 --- a/src/internal/canvas.ts +++ b/src/internal/canvas.ts @@ -3361,12 +3361,20 @@ function toGridColor(value: string): number | null | undefined { } /** - * The cell two stacked pixels make: the upper half block in the upper - * pixel's color over the lower pixel's, a lower half block when only the - * lower pixel shows, a space in one color when both match, and null when - * neither shows. A pixel at least half opaque shows; a partly - * transparent one is first blended over `under` when there is a color - * under it. A half block with no background keeps the one under it. + * The cell two stacked pixels make: the lower half block in the lower + * pixel's color over a background in the upper's, the upper half block + * when only the upper pixel shows, a space in one color when both match, + * and null when neither shows. A pixel at least half opaque shows; a + * partly transparent one is first blended over `under` when there is a + * color under it. A half block with no background keeps the one under it. + * + * Two colors go in the lower half block, not the upper, because a + * terminal that draws block characters from the font shows the font's + * glyph, and Menlo's and SF Mono's both sit low in the cell: the upper + * one leaves the cell's top in the wrong color and runs past the middle. + * The background fills whatever the glyph leaves, so with the lower half + * block only the seam is off. Terminals that draw the blocks themselves + * show either one exactly. */ export function halfBlockCell( pixels: Uint8ClampedArray, @@ -3402,8 +3410,11 @@ export function halfBlockCell( if (top === null) { return {char: "▄", style: {fg: bottom}}; } + if (bottom === null) { + return {char: "▀", style: {fg: top}}; + } if (top === bottom) { return {char: " ", style: {bg: top}}; } - return {char: "▀", style: {fg: top, bg: bottom ?? undefined}}; + return {char: "▄", style: {fg: bottom, bg: top}}; } diff --git a/src/internal/images.ts b/src/internal/images.ts index 424efaa5..a4b23a96 100644 --- a/src/internal/images.ts +++ b/src/internal/images.ts @@ -3,8 +3,8 @@ * encoding a bitmap as PNG, and sampling a bitmap down to the two * pixels a cell shows. * - * A cell draws two pixels stacked: the upper half block (U+2580) in the - * foreground color over the background color. Everything a terminal can + * A cell draws two pixels stacked: the lower half block (U+2584) in the + * foreground color under the background color. Everything a terminal can * show of an image goes through cells that way, so an image scrolls, * clips and diffs like any other text, over SSH and inside tmux too. * diff --git a/tests/canvas.test.ts b/tests/canvas.test.ts index 8fb3b52c..431c3c2d 100644 --- a/tests/canvas.test.ts +++ b/tests/canvas.test.ts @@ -131,14 +131,14 @@ test("drawing repaints the canvas's cells without any DOM change", async () => { ctx.fillRect(0, 8, 16, 8); await nextFrame(dom); expect(cell(terminal, 0, 0)).toMatchObject({ - char: "▀", - fg: 0xff0000, - bg: 0x0000ff, + char: "▄", + fg: 0x0000ff, + bg: 0xff0000, }); expect(cell(terminal, 1, 0)).toMatchObject({ - char: "▀", - fg: 0xff0000, - bg: 0x0000ff, + char: "▄", + fg: 0x0000ff, + bg: 0xff0000, }); // The second row of cells is still empty. expect(cell(terminal, 0, 1).char).toBe(" "); @@ -693,14 +693,14 @@ test("drawImage draws pixels into cells, two a cell", async () => { ctx.drawImage(document.getElementById("src"), 1, 0, 2, 1); await nextFrame(dom); expect(cell(terminal, 1, 0)).toMatchObject({ - char: "▀", - fg: 0xff0000, - bg: 0x00ff00, + char: "▄", + fg: 0x00ff00, + bg: 0xff0000, }); expect(cell(terminal, 2, 0)).toMatchObject({ - char: "▀", - fg: 0xff0000, - bg: 0x00ff00, + char: "▄", + fg: 0x00ff00, + bg: 0xff0000, }); expect(cell(terminal, 0, 0).char).toBe(" "); dom.dispose(); diff --git a/tests/img.test.ts b/tests/img.test.ts index 87dc1d31..a767f604 100644 --- a/tests/img.test.ts +++ b/tests/img.test.ts @@ -141,9 +141,9 @@ test("an image paints two pixels a cell in their own colors", async () => { [1, 1, BLUE, WHITE], ] as const) { const painted = cell(terminal, col, row); - expect(painted.char).toBe("▀"); - expect(painted.fg).toBe(hex(top)); - expect(painted.bg).toBe(hex(bottom)); + expect(painted.char).toBe("▄"); + expect(painted.fg).toBe(hex(bottom)); + expect(painted.bg).toBe(hex(top)); } // Two columns wide and no more. expect(cell(terminal, 2, 0).char).toBe(" "); @@ -266,7 +266,7 @@ test("an image sits on a line with the text around it", async () => { await mount(`

before after

`); await loaded(document.querySelector("img")!); await nextFrame(dom); - expect(rowText(terminal, 0)).toBe("before ▀▀ after"); + expect(rowText(terminal, 0)).toBe("before ▄▄ after"); // The image's computed display stays inline, as in a browser. expect( dom.window.getComputedStyle(document.querySelector("img")!).display, @@ -460,9 +460,9 @@ test("an image keeps its own height on a line with a taller neighbor", async () // tall, and the pixels are not stretched to fill it. expect(image.getBoundingClientRect().height).toBe(2); expect(cell(terminal, 0, 1)).toEqual({ - char: "▀", - fg: hex(BLUE), - bg: hex(WHITE), + char: "▄", + fg: hex(WHITE), + bg: hex(BLUE), }); expect(cell(terminal, 0, 2).char).toBe(" "); dom.dispose(); From 84d25cef666359cc9aa9084d2cbce9c21d037106 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Sun, 4 Oct 2026 16:49:46 -0400 Subject: [PATCH 10/28] Type a TermDOM's "request" listeners TermDOM became an EventTarget whose "request" events are RequestEvents, but its addEventListener took a plain Event, so every listener cast. TermDOMEventMap names the event for its type, and addEventListener and removeEventListener take keyed overloads first, as lib.dom's do. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- src/index.ts | 48 +++++++++++++++++++++++++++++++++++++++++- tests/img.test.ts | 8 +++---- tests/requests.test.ts | 8 +++---- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index 50ba8335..1e9abf26 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,7 +48,11 @@ import {setDecodeWorkerURL} from "./internal/images.ts"; import {Input} from "./internal/input.ts"; import {Layout} from "./internal/layout.ts"; import {Painter} from "./internal/painter.ts"; -import {answerRequest, setResourceLoader} from "./internal/resources.ts"; +import { + answerRequest, + type RequestEvent, + setResourceLoader, +} from "./internal/resources.ts"; export type {CellSize} from "./internal/cssom.ts"; export {transportFromProcess} from "./internal/exchange.ts"; @@ -167,6 +171,11 @@ const kHeldErrors = Symbol("heldErrors"); const kUnwrittenErrors = Symbol("unwrittenErrors"); const HELD_ERROR_LIMIT = 50; +/** The events a TermDOM dispatches, by type. */ +export interface TermDOMEventMap { + request: RequestEvent; +} + export interface TermDOM { [kFramebuffer]: Framebuffer; [kLayout]: Layout; @@ -511,6 +520,43 @@ export class TermDOM extends EventTarget { return this[kAttachReady]; } + // lib.dom's shape: the keyed overload first. + override addEventListener( + type: K, + listener: (this: TermDOM, event: TermDOMEventMap[K]) => unknown, + options?: boolean | AddEventListenerOptions, + ): void; + override addEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ): void; + override addEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ): void { + super.addEventListener(type, listener, options); + } + + override removeEventListener( + type: K, + listener: (this: TermDOM, event: TermDOMEventMap[K]) => unknown, + options?: boolean | EventListenerOptions, + ): void; + override removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions, + ): void; + override removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions, + ): void { + super.removeEventListener(type, listener, options); + } + /** * Render to ANSI at the transport's width: colors and line breaks, no * cursor controls, no modes. Without an argument, the document as it diff --git a/tests/img.test.ts b/tests/img.test.ts index a767f604..1eb27b2d 100644 --- a/tests/img.test.ts +++ b/tests/img.test.ts @@ -14,7 +14,7 @@ import {expect, test} from "@b9g/libuild/test"; -import {type RequestEvent, TermDOM} from "../src/index.ts"; +import {TermDOM} from "../src/index.ts"; import {encodePNG} from "../src/internal/images.ts"; import {MockProcess, nextFrame, scriptReplies, until} from "./test-utils.ts"; @@ -640,9 +640,9 @@ test("a new src cancels the request the old one started", async () => { const {dom, document} = await mount(""); const signals: AbortSignal[] = []; dom.addEventListener("request", (event) => { - const {signal} = (event as RequestEvent).request; + const {signal} = event.request; signals.push(signal); - (event as RequestEvent).respondWith( + event.respondWith( new Promise((_resolve, reject) => { signal.addEventListener("abort", () => reject(signal.reason)); }), @@ -699,7 +699,7 @@ test("an image is a box while it loads", async () => { const terminal = new MockProcess({cols: 40, rows: 12}); const dom = new TermDOM({transport: terminal.transport}); dom.addEventListener("request", (event) => { - (event as RequestEvent).respondWith(new Promise(() => {})); + event.respondWith(new Promise(() => {})); }); dom.document.body.innerHTML = "\"a\""; diff --git a/tests/requests.test.ts b/tests/requests.test.ts index 1b7d94f7..d579fa30 100644 --- a/tests/requests.test.ts +++ b/tests/requests.test.ts @@ -50,7 +50,7 @@ test("a listener answers what markup asks for, and says what asked", async () => const dom = create(); const seen: Array> = []; dom.addEventListener("request", (event) => { - const request = event as RequestEvent; + const request = event; seen.push(request); if (request.request.url.startsWith("cid:")) { request.respondWith(new Response(PNG)); @@ -85,7 +85,7 @@ test("a request no listener answers goes to the fetch given", async () => { test("Response.error() or a rejection refuses the request", async () => { const dom = create({fetch: async () => new Response(PNG)}); dom.addEventListener("request", (event) => { - const request = event as RequestEvent; + const request = event; request.respondWith( request.request.url.endsWith("a.png") ? Response.error() @@ -106,7 +106,7 @@ test("respondWith() is called once, during the event", async () => { let late: RequestEvent | null = null; const errors: string[] = []; dom.addEventListener("request", (event) => { - const request = event as RequestEvent; + const request = event; late = request; request.respondWith(new Response(PNG)); try { @@ -128,7 +128,7 @@ test("respondWith() is called once, during the event", async () => { test("a listener added after the constructor's markup hears its images", async () => { const dom = create({html: ""}); dom.addEventListener("request", (event) => { - (event as RequestEvent).respondWith(new Response(PNG)); + event.respondWith(new Response(PNG)); }); expect(await settled(dom.document.querySelector("img")!)).toBe("loaded"); dom.dispose(); From b80fe79eec6b2fae5992a8091af71d15c2cd8f4d Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Sun, 4 Oct 2026 16:59:29 -0400 Subject: [PATCH 11/28] Test that images and canvases a page removes are freed Each round shows four 256 by 256 images and two drawn canvases, paints, removes them and paints again; every other round removes the images before they load. Memory is the heap plus what it holds outside it, as decoded pixels are array buffers the heap does not count. A page that kept its images would hold some 100 MB over the 80 rounds: with every decoded image kept on purpose, the test reads 86 MB and fails. It passes on Node, Bun and Deno. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- tests/memory.test.ts | 70 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/memory.test.ts b/tests/memory.test.ts index 58a95f71..62d24084 100644 --- a/tests/memory.test.ts +++ b/tests/memory.test.ts @@ -6,6 +6,7 @@ import {expect, test} from "@b9g/libuild/test"; import {TermDOM} from "../src/index.ts"; +import {encodePNG} from "../src/internal/images.ts"; import {findCollector, MockProcess, nextFrame} from "./test-utils.js"; const collect = await findCollector(); @@ -196,3 +197,72 @@ async function growth(html: (round: number) => string): Promise { expect(grown).toBeLessThan(MB); }, ); + +// Pixels sit in array buffers, which the heap does not count. +async function heldMemory(): Promise { + let lowest = Infinity; + for (let i = 0; i < 3; i++) { + await settle(); + const usage = process.memoryUsage(); + lowest = Math.min(lowest, usage.heapUsed + (usage.external ?? 0)); + } + return lowest; +} + +// A 256 by 256 image is 256 KB decoded, so a page that held its images +// after they went would hold some 100 MB here. +const IMAGE = (() => { + const bytes = encodePNG({ + width: 256, + height: 256, + data: new Uint8ClampedArray(256 * 256 * 4).fill(200), + }); + let binary = ""; + for (let i = 0; i < bytes.length; i += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + } + return `data:image/png;base64,${btoa(binary)}`; +})(); + +async function showImages(dom: TermDOM, round: number): Promise { + const host = dom.document.createElement("div"); + host.innerHTML = + ``.repeat(4) + + "".repeat(2); + dom.document.body.append(host); + for (const canvas of host.querySelectorAll("canvas")) { + const context = canvas.getContext("2d")!; + context.fillStyle = `#${(round * 997 % 0xffffff).toString(16).padStart(6, "0")}`; + context.fillRect(0, 0, 256, 256); + } + // Every other round, the images go before they load. + if (round % 2 === 0) { + const images = [...host.querySelectorAll("img")]; + while (!images.every((image) => image.complete)) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + } + await nextFrame(dom); + host.remove(); + await nextFrame(dom); +} + +(collect === null ? test.skip : test)( + "showing and removing images and canvases does not grow memory", + async () => { + const dom = new TermDOM({ + transport: new MockProcess({cols: 100, rows: 40}).transport, + fetch: (request) => fetch(request), + }); + for (let round = 0; round < WARM_UP; round++) { + await showImages(dom, round); + } + const before = await heldMemory(); + for (let round = WARM_UP; round < WARM_UP + ROUNDS; round++) { + await showImages(dom, round); + } + const grown = (await heldMemory() - before) / 1e6; + dom.dispose(); + expect(grown).toBeLessThan(MB); + }, +); From 54c345aacd917fa42a1e6ea8005534a3101c0ebd Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Sun, 4 Oct 2026 17:07:44 -0400 Subject: [PATCH 12/28] Keep at most a megapixel of a decoded image Each image was kept decoded at full size, up to 2^26 pixels and 256 MB, though a terminal shows a few hundred thousand pixels at most: a 12 megapixel photo held 48 MB to draw in 40 columns. An image past MAX_KEPT_PIXELS (2^20, 4 MB) is now averaged down to it as it decodes, on the worker or here, and keeps its natural size beside the pixels. That photo keeps 1182 by 886 pixels, 4.2 MB. What a page measures stays the natural size: naturalWidth and naturalHeight, layout and the natural ratio, drawImage's source rectangle, createImageBitmap's crop and size, and the tile a pattern repeats at. sampleBitmap takes its rectangle in natural pixels and maps it to the pixels kept, and the 2d context's drawImage does the same. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- src/decode-worker.ts | 16 +++++++--- src/internal/canvas.ts | 61 ++++++++++++++++++++++++----------- src/internal/dom.ts | 8 +++-- src/internal/images.ts | 68 ++++++++++++++++++++++++++++++++++++---- src/internal/replaced.ts | 24 ++++++++++---- tests/images.test.ts | 19 +++++++++++ tests/img.test.ts | 33 +++++++++++++++++++ 7 files changed, 193 insertions(+), 36 deletions(-) diff --git a/src/decode-worker.ts b/src/decode-worker.ts index 3c2eb6f5..2d38e11d 100644 --- a/src/decode-worker.ts +++ b/src/decode-worker.ts @@ -1,13 +1,14 @@ /** * The thread images decode on. It takes `{id, bytes}` and answers - * `{id, width, height, data}` with the pixels' buffer transferred, or + * `{id, width, height, naturalWidth, naturalHeight, data}` with the + * pixels' buffer transferred, kept at most MAX_KEPT_PIXELS, or * `{id, error}`. It answers `{ready: true}` first, so the page can tell * a worker that never started from one that failed on an image. * * A web worker answers through its global scope. Node's worker_threads * answer through parentPort. */ -import {decodeImage} from "./internal/images.ts"; +import {decodeImageForPage} from "./internal/images.ts"; interface Port { postMessage(message: unknown, transfer?: Transferable[]): void; @@ -40,10 +41,17 @@ const port: Port = nodeParent ?? (globalThis as unknown as Port); async function answer(data: unknown): Promise { const {id, bytes} = data as {id: number; bytes: ArrayBuffer}; try { - const bitmap = await decodeImage(new Uint8Array(bytes)); + const bitmap = await decodeImageForPage(new Uint8Array(bytes)); const pixels = bitmap.data.buffer as ArrayBuffer; port.postMessage( - {id, width: bitmap.width, height: bitmap.height, data: pixels}, + { + id, + width: bitmap.width, + height: bitmap.height, + naturalWidth: bitmap.naturalWidth, + naturalHeight: bitmap.naturalHeight, + data: pixels, + }, [pixels], ); } catch (error) { diff --git a/src/internal/canvas.ts b/src/internal/canvas.ts index 30cca78f..8353643e 100644 --- a/src/internal/canvas.ts +++ b/src/internal/canvas.ts @@ -26,6 +26,8 @@ import { createBitmap, encodePNG, MAX_IMAGE_PIXELS, + naturalHeightOf, + naturalWidthOf, sampleBitmap, } from "./images.ts"; import {getStringWidth, graphemeSegmenter} from "./text.ts"; @@ -136,11 +138,13 @@ export class ImageBitmap { } get width(): number { - return this[kBitmapData]?.width ?? 0; + const bitmap = this[kBitmapData]; + return bitmap ? naturalWidthOf(bitmap) : 0; } get height(): number { - return this[kBitmapData]?.height ?? 0; + const bitmap = this[kBitmapData]; + return bitmap ? naturalHeightOf(bitmap) : 0; } close(): void { @@ -165,14 +169,28 @@ export function createImageBitmapFrom( } const x = Math.floor(sx ?? 0); const y = Math.floor(sy ?? 0); - const width = Math.floor(sw ?? bitmap.width); - const height = Math.floor(sh ?? bitmap.height); + const width = Math.floor(sw ?? naturalWidthOf(bitmap)); + const height = Math.floor(sh ?? naturalHeightOf(bitmap)); if (width <= 0 || height <= 0) { throw domException("IndexSizeError", "The crop rectangle is empty"); } - const copy = createBitmap(width, height); - copyRect(bitmap, x, y, copy, 0, 0, width, height); - return new ImageBitmap(copy); + if (bitmap.width === naturalWidthOf(bitmap)) { + const copy = createBitmap(width, height); + copyRect(bitmap, x, y, copy, 0, 0, width, height); + return new ImageBitmap(copy); + } + // From an image kept smaller than it is, the copy is kept as small, + // and measures the crop. + const kept = bitmap.width / naturalWidthOf(bitmap); + const keptWidth = Math.max(1, Math.round(width * kept)); + const keptHeight = Math.max(1, Math.round(height * kept)); + return new ImageBitmap({ + width: keptWidth, + height: keptHeight, + data: sampleBitmap(bitmap, keptWidth, keptHeight, {x, y, width, height}), + naturalWidth: width, + naturalHeight: height, + }); } function copyRect( @@ -1474,9 +1492,11 @@ export class CanvasPattern { if (inverse !== null) { [x, y] = apply(inverse, x, y); } - const {width, height, data} = this[kPatternBitmap]; - let px = Math.floor(x); - let py = Math.floor(y); + const bitmap = this[kPatternBitmap]; + const {width, height, data} = bitmap; + // The pattern tiles at the image's natural size. + let px = Math.floor((x * width) / naturalWidthOf(bitmap)); + let py = Math.floor((y * height) / naturalHeightOf(bitmap)); if (this[kRepeatX]) { px = ((px % width) + width) % width; } @@ -2107,6 +2127,8 @@ export class CanvasRenderingContext2D { } const copy = createBitmap(bitmap.width, bitmap.height); copy.data.set(bitmap.data); + copy.naturalWidth = naturalWidthOf(bitmap); + copy.naturalHeight = naturalHeightOf(bitmap); return new CanvasPattern(copy, mode); } @@ -2363,8 +2385,8 @@ export class CanvasRenderingContext2D { } let sx = 0; let sy = 0; - let sw = bitmap.width; - let sh = bitmap.height; + let sw = naturalWidthOf(bitmap); + let sh = naturalHeightOf(bitmap); let dx: number; let dy: number; let dw: number; @@ -2401,8 +2423,8 @@ export class CanvasRenderingContext2D { const scaleY = dh / sh; const clippedX = Math.max(0, sx); const clippedY = Math.max(0, sy); - const clippedRight = Math.min(bitmap.width, sx + sw); - const clippedBottom = Math.min(bitmap.height, sy + sh); + const clippedRight = Math.min(naturalWidthOf(bitmap), sx + sw); + const clippedBottom = Math.min(naturalHeightOf(bitmap), sy + sh); if (clippedRight <= clippedX || clippedBottom <= clippedY) { return; } @@ -2438,9 +2460,12 @@ export class CanvasRenderingContext2D { const clip = this[kState].clip; const smooth = this[kState].imageSmoothingEnabled; const {width, height, data} = target; + // The source rectangle is in natural pixels. An image may keep fewer. + const keptX = source.width / naturalWidthOf(source); + const keptY = source.height / naturalHeightOf(source); const sample = (u: number, v: number): RGBA => { - const px = sx + ((u - dx) / dw) * sw; - const py = sy + ((v - dy) / dh) * sh; + const px = (sx + ((u - dx) / dw) * sw) * keptX; + const py = (sy + ((v - dy) / dh) * sh) * keptY; if (!smooth) { const ix = Math.min(source.width - 1, Math.max(0, Math.floor(px))); const iy = Math.min(source.height - 1, Math.max(0, Math.floor(py))); @@ -3199,8 +3224,8 @@ export class CanvasCharacterGridContext { } let sx = 0; let sy = 0; - let sw = bitmap.width; - let sh = bitmap.height; + let sw = naturalWidthOf(bitmap); + let sh = naturalHeightOf(bitmap); let dx: number; let dy: number; let dw: number; diff --git a/src/internal/dom.ts b/src/internal/dom.ts index 0d82702c..146af320 100644 --- a/src/internal/dom.ts +++ b/src/internal/dom.ts @@ -63,6 +63,8 @@ import { decodeImageOffThread, IMAGE_ACCEPT, MAX_IMAGE_PIXELS, + naturalHeightOf, + naturalWidthOf, readImageResponse, registerReplacedContent, resolveImageURL, @@ -16589,11 +16591,13 @@ class HTMLImageElement extends HTMLElement { } get naturalWidth(): number { - return this[kImageState].bitmap?.width ?? 0; + const bitmap = this[kImageState].bitmap; + return bitmap === null ? 0 : naturalWidthOf(bitmap); } get naturalHeight(): number { - return this[kImageState].bitmap?.height ?? 0; + const bitmap = this[kImageState].bitmap; + return bitmap === null ? 0 : naturalHeightOf(bitmap); } // The rendered width in cells when the image is rendered, which is diff --git a/src/internal/images.ts b/src/internal/images.ts index a4b23a96..9f1f6b1b 100644 --- a/src/internal/images.ts +++ b/src/internal/images.ts @@ -19,6 +19,18 @@ export interface Bitmap { width: number; height: number; data: Uint8ClampedArray; + // The size the image is, when its pixels were kept smaller than that. + // Everything outside the pixels themselves measures this one. + naturalWidth?: number; + naturalHeight?: number; +} + +export function naturalWidthOf(bitmap: Bitmap): number { + return bitmap.naturalWidth ?? bitmap.width; +} + +export function naturalHeightOf(bitmap: Bitmap): number { + return bitmap.naturalHeight ?? bitmap.height; } /** @@ -76,6 +88,36 @@ function createYielder(): () => Promise | undefined { }; } +/** + * The most pixels a decoded image keeps, 4 MB of them. A terminal shows a + * few hundred thousand at most, so a larger image is kept averaged down + * to this, and reports its natural size as before. + */ +export const MAX_KEPT_PIXELS = 1 << 20; + +/** The image as a page keeps it: at most MAX_KEPT_PIXELS. */ +export function keepAtMost(bitmap: Bitmap): Bitmap { + const {width, height} = bitmap; + if (width * height <= MAX_KEPT_PIXELS) { + return bitmap; + } + const scale = Math.sqrt(MAX_KEPT_PIXELS / (width * height)); + const keptWidth = Math.max(1, Math.floor(width * scale)); + const keptHeight = Math.max(1, Math.floor(height * scale)); + return { + width: keptWidth, + height: keptHeight, + data: sampleBitmap(bitmap, keptWidth, keptHeight), + naturalWidth: width, + naturalHeight: height, + }; +} + +/** Decode, and keep at most MAX_KEPT_PIXELS. */ +export async function decodeImageForPage(bytes: Uint8Array): Promise { + return keepAtMost(await decodeImage(bytes)); +} + export function createBitmap(width: number, height: number): Bitmap { return {width, height, data: new Uint8ClampedArray(width * height * 4)}; } @@ -1852,21 +1894,31 @@ export function sampleBitmap( bitmap: Bitmap, outWidth: number, outHeight: number, - source: {x: number; y: number; width: number; height: number} = { + natural: {x: number; y: number; width: number; height: number} = { x: 0, y: 0, - width: bitmap.width, - height: bitmap.height, + width: naturalWidthOf(bitmap), + height: naturalHeightOf(bitmap), }, smooth = true, ): Uint8ClampedArray { const out = new Uint8ClampedArray(Math.max(0, outWidth * outHeight * 4)); if ( - outWidth <= 0 || outHeight <= 0 || source.width <= 0 || source.height <= 0 + outWidth <= 0 || outHeight <= 0 || natural.width <= 0 || natural.height <= 0 ) { return out; } const {width, height, data} = bitmap; + // The rectangle is in the image's natural pixels, and the pixels kept + // may be fewer. + const keptX = width / naturalWidthOf(bitmap); + const keptY = height / naturalHeightOf(bitmap); + const source = { + x: natural.x * keptX, + y: natural.y * keptY, + width: natural.width * keptX, + height: natural.height * keptY, + }; const scaleX = source.width / outWidth; const scaleY = source.height / outHeight; for (let oy = 0; oy < outHeight; oy++) { @@ -2150,7 +2202,7 @@ export function setDecodeTimeout(ms: number): void { export function decodeImageOffThread(bytes: Uint8Array): Promise { const state = workersWork ? getWorker() : null; if (state === null) { - return decodeImage(bytes); + return decodeImageForPage(bytes); } const id = nextRequest++; // A copy the worker can take, whatever buffer the bytes sit in. @@ -2197,6 +2249,8 @@ function getWorker(): WorkerState | null { id?: number; width?: number; height?: number; + naturalWidth?: number; + naturalHeight?: number; data?: ArrayBuffer; error?: string; }; @@ -2218,6 +2272,8 @@ function getWorker(): WorkerState | null { width: message.width!, height: message.height!, data: new Uint8ClampedArray(message.data!), + naturalWidth: message.naturalWidth, + naturalHeight: message.naturalHeight, }); } if (state.pending.size === 0) { @@ -2239,7 +2295,7 @@ function getWorker(): WorkerState | null { for (const [id, bytes] of queued) { const request = pending.get(id)!; clearTimeout(request.timer); - decodeImage(bytes).then(request.resolve, request.reject); + decodeImageForPage(bytes).then(request.resolve, request.reject); } return; } diff --git a/src/internal/replaced.ts b/src/internal/replaced.ts index 15ad040e..72700863 100644 --- a/src/internal/replaced.ts +++ b/src/internal/replaced.ts @@ -30,6 +30,8 @@ import { getReplacedContent, isReplacedElement, MAX_IMAGE_PIXELS, + naturalHeightOf, + naturalWidthOf, type ReplacedContent, sampleBitmap, } from "./images.ts"; @@ -53,7 +55,12 @@ export function getNaturalSize( // inside one when there is none. return {width: Math.max(1, textWidth(content.text)) + 2, height: 3}; } - const {width, height} = content.kind === "blank" ? content : content.bitmap; + const {width, height} = content.kind === "blank" + ? content + : { + width: naturalWidthOf(content.bitmap), + height: naturalHeightOf(content.bitmap), + }; if (width === 0 || height === 0) { return {width: 0, height: 0}; } @@ -75,7 +82,12 @@ export function getNaturalRatio( if (content.kind === "grid") { return content.grid.rows > 0 ? content.grid.cols / content.grid.rows : NaN; } - const {width, height} = content.kind === "blank" ? content : content.bitmap; + const {width, height} = content.kind === "blank" + ? content + : { + width: naturalWidthOf(content.bitmap), + height: naturalHeightOf(content.bitmap), + }; if (width === 0 || height === 0) { return NaN; } @@ -350,8 +362,8 @@ function getBitmapCells( ): CellGrid { const {bitmap} = content; const cell = getImageCellSize(element as unknown as Node); - const naturalWidth = bitmap.width / cell.width; - const naturalHeight = bitmap.height / cell.height; + const naturalWidth = naturalWidthOf(bitmap) / cell.width; + const naturalHeight = naturalHeightOf(bitmap) / cell.height; const place = placeImage(element, cols, rows, naturalWidth, naturalHeight); const rendering = getComputedValue(element, "image-rendering") .trim() @@ -381,8 +393,8 @@ function getBitmapCells( const bottom = Math.min(rows * 2, Math.round(place.y + place.height)); const pixels = new Uint8ClampedArray(cols * rows * 2 * 4); if (right > left && bottom > top && place.width > 0 && place.height > 0) { - const scaleX = bitmap.width / place.width; - const scaleY = bitmap.height / place.height; + const scaleX = naturalWidthOf(bitmap) / place.width; + const scaleY = naturalHeightOf(bitmap) / place.height; const sampled = sampleBitmap( bitmap, right - left, diff --git a/tests/images.test.ts b/tests/images.test.ts index fed54e0d..e90f31c9 100644 --- a/tests/images.test.ts +++ b/tests/images.test.ts @@ -15,7 +15,9 @@ import { decodeImage, decodeImageOffThread, encodePNG, + keepAtMost, MAX_IMAGE_BYTES, + MAX_KEPT_PIXELS, readImageResponse, resolveImageURL, sampleBitmap, @@ -497,3 +499,20 @@ test("where no worker starts, images decode on this thread", async () => { setDecodeWorkerURL(null); } }); + +test("an image past MAX_KEPT_PIXELS is kept averaged down, with its natural size", () => { + const width = 2000; + const height = 1000; + const data = new Uint8ClampedArray(width * height * 4); + for (let i = 0; i < width * height; i++) { + data.set(i % width < 1000 ? [255, 0, 0, 255] : [0, 0, 255, 255], i * 4); + } + const kept = keepAtMost({width, height, data}); + expect(kept.width * kept.height).toBeLessThanOrEqual(MAX_KEPT_PIXELS); + expect([kept.naturalWidth, kept.naturalHeight]).toEqual([2000, 1000]); + expect(kept.width / kept.height).toBeCloseTo(2, 1); + expect(pixel(kept, 0, 0)).toEqual([255, 0, 0, 255]); + expect(pixel(kept, kept.width - 1, 0)).toEqual([0, 0, 255, 255]); + const small = {width: 10, height: 10, data: new Uint8ClampedArray(400)}; + expect(keepAtMost(small)).toBe(small); +}); diff --git a/tests/img.test.ts b/tests/img.test.ts index 1eb27b2d..fc2d8c31 100644 --- a/tests/img.test.ts +++ b/tests/img.test.ts @@ -708,3 +708,36 @@ test("an image is a box while it loads", async () => { expect(rowText(terminal, 1)).toBe("│a│"); dom.dispose(); }); + +test("an image larger than a page keeps measures as large as it is", async () => { + // 1500 by 800 is past MAX_KEPT_PIXELS, so fewer pixels are kept. Red + // on the left half, blue on the right. + const wide = png(1500, 800, (x) => x < 750 ? RED : BLUE); + const {dom, document} = await mount( + `` + + "", + ); + const image = document.getElementById("a") as HTMLImageElement; + await loaded(image); + await nextFrame(dom); + expect([image.naturalWidth, image.naturalHeight]).toEqual([1500, 800]); + // 1500 by 800 pixels under the 8 by 16 guess is 187.5 by 50 cells. + const box = image.getBoundingClientRect(); + expect(box.height).toBe(Math.round(40 * 50 / 187.5)); + + const canvas = document.getElementById("c") as HTMLCanvasElement; + const context = canvas.getContext("2d")!; + // A source rectangle in the image's own pixels: its right half. + context.drawImage(image, 750, 0, 750, 800, 0, 0, 10, 10); + expect([...context.getImageData(5, 5, 1, 1).data]).toEqual(BLUE); + + const bitmap = await dom.window.createImageBitmap(image); + expect([bitmap.width, bitmap.height]).toEqual([1500, 800]); + + const pattern = context.createPattern(image, "repeat")!; + context.fillStyle = pattern; + context.fillRect(0, 0, 1500, 800); + expect([...context.getImageData(100, 400, 1, 1).data]).toEqual(RED); + expect([...context.getImageData(1400, 400, 1, 1).data]).toEqual(BLUE); + dom.dispose(); +}); From fbaca15caffc84251f503b2417429fd405d21424 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Sun, 4 Oct 2026 17:52:15 -0400 Subject: [PATCH 13/28] Say in the README that images load only when the program says so Also stop exporting MAX_IMAGE_SIDE, checkImageSize and ResourceLoader, which only their own files use. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- README.md | 8 +++++--- src/internal/images.ts | 8 ++------ src/internal/resources.ts | 2 +- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c233cb3d..178fa87a 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,11 @@ setInterval(() => { by grapheme, and text fields bind the readline chords (Ctrl+A/E/K/U/W). - **Fullscreen** `Element.requestFullscreen()` renders to the alternate screen; exiting restores the shell and its scrollback. -- **Images and canvas** `` decodes PNG, JPEG, GIF and BMP, and - `` has a 2D context and a `"charactergrid"` context; pixels - are drawn two to a cell with half blocks. +- **Images and canvas** `` decodes PNG, JPEG, GIF and BMP on a + worker, and `` has a 2D context and a `"charactergrid"` + context; pixels are drawn two to a cell with half blocks. Images load + only when the program says so: `new TermDOM({fetch})`, or a + `"request"` listener that answers each load. ## How it works diff --git a/src/internal/images.ts b/src/internal/images.ts index 9f1f6b1b..d65faebf 100644 --- a/src/internal/images.ts +++ b/src/internal/images.ts @@ -39,15 +39,11 @@ export function naturalHeightOf(bitmap: Bitmap): number { * pixels, so a few bytes can ask for any amount of memory. Like a * browser, a decoder refuses a size past these before it allocates. */ -export const MAX_IMAGE_SIDE = 32768; +const MAX_IMAGE_SIDE = 32768; export const MAX_IMAGE_PIXELS = 1 << 26; /** Throws for a size an image may not have. */ -export function checkImageSize( - width: number, - height: number, - format: string, -): void { +function checkImageSize(width: number, height: number, format: string): void { if ( !Number.isInteger(width) || !Number.isInteger(height) || diff --git a/src/internal/resources.ts b/src/internal/resources.ts index abcbc3d3..3e02547b 100644 --- a/src/internal/resources.ts +++ b/src/internal/resources.ts @@ -81,7 +81,7 @@ export class RequestEvent extends Event { } /** How a document's loads are answered, set by the TermDOM that owns it. */ -export type ResourceLoader = ( +type ResourceLoader = ( request: Request, context: RequestContext, ) => Promise; From 1048e5f34fe224f0075b676053edaf2d3fb51469 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Sun, 4 Oct 2026 17:57:34 -0400 Subject: [PATCH 14/28] Resolve image sources against the document only, and keep files to file: pages A relative src in a page at about:blank resolved under the process's working directory, which TermDOM read for it. The engine reads only what the terminal sends, so a relative src now resolves against the document's URL or not at all; a program that shows local files sets the url option to one, such as its working directory, as the images example does. The file: rule exempted about: pages, which left a default TermDOM's untrusted markup free to ask for file:///etc/hosts, and Bun's and Deno's fetch read it. Now only a document whose own URL is file: loads file: URLs. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- docs/guides/07-images-and-canvas.md | 14 ++++++++------ examples/images.ts | 5 ++++- src/internal/images.ts | 18 ++++-------------- src/internal/resources.ts | 7 ++++--- tests/images.test.ts | 9 +++++---- tests/requests.test.ts | 20 ++++++++++++++++++-- 6 files changed, 43 insertions(+), 30 deletions(-) diff --git a/docs/guides/07-images-and-canvas.md b/docs/guides/07-images-and-canvas.md index 56a78706..2cfb8cd0 100644 --- a/docs/guides/07-images-and-canvas.md +++ b/docs/guides/07-images-and-canvas.md @@ -23,9 +23,10 @@ redraw like text, and they work over SSH and inside tmux. ``` `src` is a URL: `https:`, `data:`, `file:` or any other. A path is -relative to the document's URL. A document at `about:blank` has no URL -to resolve against, so a path there is a `file:` URL under the working -directory. +relative to the document's URL, given by the `url` option. A document +at `about:blank` has no URL to resolve against, so a path there names +nothing; a program that shows local files sets `url` to a `file:` URL, +such as its working directory. ### Loading @@ -58,9 +59,10 @@ element that asked. `Response.error()`, or a promise that rejects, fails the request. The request waits for the script that set the source to finish, so a -listener added just after the markup sees its images. A document whose -URL is not `file:` or `about:` loads no `file:` URL, whatever would -answer it. Node's `fetch` reads no files, and Bun's and Deno's do, so a +listener added just after the markup sees its images. Only a document +whose own URL is `file:` loads `file:` URLs, whatever would answer +them, so untrusted markup in a page at `about:blank` or `https:` cannot +read local files. Node's `fetch` reads no files, and Bun's and Deno's do, so a program that wants local files on every runtime answers them itself. The page's own `window.fetch` is the `fetch` given, or the runtime's diff --git a/examples/images.ts b/examples/images.ts index 851c0dd1..bb5409d8 100644 --- a/examples/images.ts +++ b/examples/images.ts @@ -1,4 +1,5 @@ import {readFile} from "node:fs/promises"; +import {pathToFileURL} from "node:url"; import {TermDOM} from "@b9g/termdom"; @@ -10,8 +11,10 @@ const source = typeof process === "undefined" ? undefined : process.argv[2]; // Images load only through the fetch a TermDOM is given. This one reads // a file: URL from disk, which Node's own fetch does not, and leaves the -// rest to the runtime. +// rest to the runtime. The document's URL is the working directory, so +// a path given on the command line resolves under it. const term = new TermDOM({ + url: pathToFileURL(`${process.cwd()}/`).href, async fetch(request) { if (request.url.startsWith("file:")) { return new Response(await readFile(new URL(request.url))); diff --git a/src/internal/images.ts b/src/internal/images.ts index d65faebf..59a4a668 100644 --- a/src/internal/images.ts +++ b/src/internal/images.ts @@ -2048,7 +2048,6 @@ export function isReplacedElement(owner: object): boolean { // Reading what a src names. interface ProcessWithBuiltins { - cwd?: () => string; getBuiltinModule?: (name: string) => unknown; } @@ -2057,25 +2056,16 @@ function getProcess(): ProcessWithBuiltins | undefined { } /** - * Where a relative src points. A document at about:blank has no base, - * and a program in a terminal works in its working directory, so a - * relative path there is a file under it. + * The URL a src names, against the document's URL, or null. A relative + * src in a document with no URL of its own, at about:blank, names + * nothing. */ export function resolveImageURL(src: string, base: string): string | null { // A Windows path's drive letter would read as a URL scheme. if (/^[a-z]:[\\/]/i.test(src)) { return new URL(`file:///${src.replace(/\\/g, "/")}`).href; } - if (URL.canParse(src, base)) { - return new URL(src, base).href; - } - const process = getProcess(); - if (process?.cwd === undefined) { - return null; - } - const dir = process.cwd().replace(/\\/g, "/"); - const root = `file://${dir.startsWith("/") ? "" : "/"}${dir}/`; - return URL.canParse(src, root) ? new URL(src, root).href : null; + return URL.canParse(src, base) ? new URL(src, base).href : null; } /** The most bytes an image file may have before it decodes. */ diff --git a/src/internal/resources.ts b/src/internal/resources.ts index 3e02547b..aab1cb83 100644 --- a/src/internal/resources.ts +++ b/src/internal/resources.ts @@ -5,7 +5,8 @@ * what asked for it. A listener answers with respondWith(), as a * Service Worker answers a fetch event. A request no listener answers * goes to the `fetch` the TermDOM was made with, and without one it - * fails: nothing loads unless the program says so. + * fails: nothing loads unless the program says so. Only a document that + * is itself a file: URL loads file: URLs. */ /** What a RequestEvent carries besides the request itself. */ @@ -114,7 +115,7 @@ export function loadResource( /** * Dispatch the request to `target`'s listeners and settle it: their * answer, else `fallback`, else a network error. As in a browser, a - * document that is not a file loads no file. + * document that is not a file loads no file, about:blank included. */ export function answerRequest( target: EventTarget, @@ -125,7 +126,7 @@ export function answerRequest( ): Promise { if ( new URL(request.url).protocol === "file:" && - !/^(?:file|about):/.test(documentURL) + !documentURL.startsWith("file:") ) { return Promise.reject( new TypeError("A document that is not a file cannot load a file"), diff --git a/tests/images.test.ts b/tests/images.test.ts index e90f31c9..6812db27 100644 --- a/tests/images.test.ts +++ b/tests/images.test.ts @@ -302,13 +302,14 @@ test("a Windows path is a file URL, not a URL with a drive-letter scheme", () => ); }); -test("a relative src resolves against the document, or the working directory", () => { +test("a relative src resolves against the document's URL, and at about:blank to nothing", () => { expect(resolveImageURL("b.png", "https://example.com/a/")).toBe( "https://example.com/a/b.png", ); - const local = resolveImageURL("art/cover.png", "about:blank"); - expect(local?.startsWith("file:///")).toBe(true); - expect(local?.endsWith("/art/cover.png")).toBe(true); + expect(resolveImageURL("art/cover.png", "file:///home/me/")).toBe( + "file:///home/me/art/cover.png", + ); + expect(resolveImageURL("art/cover.png", "about:blank")).toBeNull(); }); // Images that ask for more than they are: a header's size, a stream diff --git a/tests/requests.test.ts b/tests/requests.test.ts index d579fa30..d02242fc 100644 --- a/tests/requests.test.ts +++ b/tests/requests.test.ts @@ -134,7 +134,7 @@ test("a listener added after the constructor's markup hears its images", async ( dom.dispose(); }); -test("a document that is not a file loads no file, whatever answers", async () => { +test("only a document that is a file loads files, whatever answers", async () => { let heard = 0; const dom = create({ url: "https://mail.example/message/1", @@ -148,9 +148,25 @@ test("a document that is not a file loads no file, whatever answers", async () = expect(heard).toBe(0); dom.dispose(); - const local = create({fetch: async () => new Response(PNG)}); + const blank = create({fetch: async () => new Response(PNG)}); + blank.document.body.innerHTML = + ""; + const blankImages = [...blank.document.querySelectorAll("img")]; + expect(await Promise.all(blankImages.map(settled))) + .toEqual(["broken", "broken"]); + blank.dispose(); + + const asked: string[] = []; + const local = create({ + url: "file:///home/me/art/", + fetch: async (request) => { + asked.push(request.url); + return new Response(PNG); + }, + }); local.document.body.innerHTML = ""; expect(await settled(local.document.querySelector("img")!)).toBe("loaded"); + expect(asked).toEqual(["file:///home/me/art/cover.png"]); local.dispose(); }); From 4569a9147a788965c90339ebc823b5f9347f8954 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Sun, 4 Oct 2026 18:00:36 -0400 Subject: [PATCH 15/28] Resolve window.fetch's URL against the document, and close failed responses The page calls fetch(input, init), but the fetch option takes a Request, and the runtime's fetch knows nothing of the document's URL: a page's fetch("/data") reached the option as a string and an init, or failed as an invalid URL. window.fetch now builds the Request, resolving a relative URL against the document as a browser does, and hands it to the fetch given or the runtime's. An image response that failed on its status or its declared length left its body unread, which holds the connection open; it is now cancelled. A waitUntil() promise that rejects no longer goes unhandled. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- docs/guides/07-images-and-canvas.md | 3 ++- src/index.ts | 13 ++++++++++--- src/internal/images.ts | 6 +++++- src/internal/resources.ts | 3 ++- tests/requests.test.ts | 14 +++++++++++--- 5 files changed, 30 insertions(+), 9 deletions(-) diff --git a/docs/guides/07-images-and-canvas.md b/docs/guides/07-images-and-canvas.md index 2cfb8cd0..2e23a240 100644 --- a/docs/guides/07-images-and-canvas.md +++ b/docs/guides/07-images-and-canvas.md @@ -66,7 +66,8 @@ read local files. Node's `fetch` reads no files, and Bun's and Deno's do, so a program that wants local files on every runtime answers them itself. The page's own `window.fetch` is the `fetch` given, or the runtime's -when there is none. It dispatches no event: a script that calls it has +when there is none, and it resolves a relative URL against the +document's, as a browser's does. It dispatches no event: a script that calls it has asked for the load. PNG, JPEG, GIF and BMP decode: diff --git a/src/index.ts b/src/index.ts index 1e9abf26..bd55f6f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -265,9 +265,16 @@ export class TermDOM extends EventTarget { ); const document = this.document = this.window.document; - if (options.fetch !== undefined) { - this.window.fetch = options.fetch as typeof this.window.fetch; - } + // The page's fetch resolves a relative URL against the document, as a + // browser's does, and goes to the fetch given or the runtime's. + const pageFetch = options.fetch ?? ((request: Request) => fetch(request)); + this.window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => + pageFetch( + new Request( + typeof input === "string" ? new URL(input, document.baseURI) : input, + init, + ), + )) as typeof this.window.fetch; setResourceLoader(document, (request, context) => answerRequest(this, document.URL, request, context, options.fetch), ); diff --git a/src/internal/images.ts b/src/internal/images.ts index 59a4a668..67d1c06a 100644 --- a/src/internal/images.ts +++ b/src/internal/images.ts @@ -2091,11 +2091,15 @@ export const IMAGE_ACCEPT = export async function readImageResponse( response: Response, ): Promise { + // A body left unread holds its connection open. + const discard = () => response.body?.cancel().catch(() => {}); if (!response.ok) { + await discard(); throw new Error(`The image request failed with status ${response.status}`); } const declared = Number(response.headers.get("content-length") ?? NaN); - if (Number.isFinite(declared)) { + if (Number.isFinite(declared) && declared > MAX_IMAGE_BYTES) { + await discard(); checkImageBytes(declared); } if (response.body === null) { diff --git a/src/internal/resources.ts b/src/internal/resources.ts index aab1cb83..99c27177 100644 --- a/src/internal/resources.ts +++ b/src/internal/resources.ts @@ -77,7 +77,8 @@ export class RequestEvent extends Event { /** Work the listener goes on with after it answers. */ waitUntil(promise: PromiseLike): void { - this[kExtensions].push(Promise.resolve(promise)); + // Nothing waits on it, so a rejection must not go unhandled. + this[kExtensions].push(Promise.resolve(promise).catch(() => {})); } } diff --git a/tests/requests.test.ts b/tests/requests.test.ts index d02242fc..990d0943 100644 --- a/tests/requests.test.ts +++ b/tests/requests.test.ts @@ -171,9 +171,17 @@ test("only a document that is a file loads files, whatever answers", async () => }); test("window.fetch is the fetch given, and the runtime's without one", async () => { - const given = async () => new Response("given"); - const dom = create({fetch: given}); - expect(dom.window.fetch).toBe(given); + const asked: Array<[string, string]> = []; + const dom = create({ + url: "https://app.example/", + fetch: async (request) => { + asked.push([request.method, request.url]); + return new Response("given"); + }, + }); + const answer = await dom.window.fetch("/data", {method: "POST", body: "x"}); + expect(await answer.text()).toBe("given"); + expect(asked).toEqual([["POST", "https://app.example/data"]]); dom.dispose(); const plain = create(); From 999896d4181dd86f0ba7b33bfb4a0bd025844ccd Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Sun, 4 Oct 2026 18:05:09 -0400 Subject: [PATCH 16/28] Decode images one at a time, and time each decode by itself A slow image used to end the worker, failing every image it held, and its timer counted time spent waiting in line. Now one image is on the worker at a time and its timer starts when it is sent. A timeout or a crash fails that image alone, and a new worker takes the next. Decoding on the page's thread waits in the same line and stops at its next yield once past the same limit. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- src/internal/images.ts | 263 +++++++++++++++++++++++++---------------- tests/images.test.ts | 87 ++++++++++++-- 2 files changed, 233 insertions(+), 117 deletions(-) diff --git a/src/internal/images.ts b/src/internal/images.ts index 67d1c06a..be2a4db2 100644 --- a/src/internal/images.ts +++ b/src/internal/images.ts @@ -69,16 +69,21 @@ function checkImageSize(width: number, height: number, format: string): void { const DECODE_SLICE_MS = 16; // Returns a promise to await when a turn is due, and undefined between. -function createYielder(): () => Promise | undefined { +// Past `deadline`, a time from Date.now(), the promise rejects instead. +function createYielder(deadline: number): () => Promise | undefined { let last = performance.now(); return () => { if (performance.now() - last < DECODE_SLICE_MS) { return undefined; } - return new Promise((resolve) => { + return new Promise((resolve, reject) => { setTimeout(() => { last = performance.now(); - resolve(); + if (Date.now() > deadline) { + reject(new Error("The image took too long to decode")); + } else { + resolve(); + } }, 0); }); }; @@ -109,9 +114,15 @@ export function keepAtMost(bitmap: Bitmap): Bitmap { }; } -/** Decode, and keep at most MAX_KEPT_PIXELS. */ -export async function decodeImageForPage(bytes: Uint8Array): Promise { - return keepAtMost(await decodeImage(bytes)); +/** + * Decode, and keep at most MAX_KEPT_PIXELS. A decode still running at + * `deadline` stops at its next yield. + */ +export async function decodeImageForPage( + bytes: Uint8Array, + deadline = Infinity, +): Promise { + return keepAtMost(await decodeImage(bytes, deadline)); } export function createBitmap(width: number, height: number): Bitmap { @@ -151,12 +162,15 @@ export function sniffImageType(bytes: Uint8Array): string | null { * sniffs it, not from a name or a content type. Rejects with the reason * a format is unsupported or the data is damaged. */ -export async function decodeImage(bytes: Uint8Array): Promise { +export async function decodeImage( + bytes: Uint8Array, + deadline = Infinity, +): Promise { switch (sniffImageType(bytes)) { case "image/png": - return await decodePNG(bytes); + return await decodePNG(bytes, deadline); case "image/jpeg": - return await decodeJPEG(bytes); + return await decodeJPEG(bytes, deadline); case "image/gif": return decodeGIF(bytes); case "image/bmp": @@ -237,7 +251,7 @@ function getPNGDataSize( return size; } -async function decodePNG(bytes: Uint8Array): Promise { +async function decodePNG(bytes: Uint8Array, deadline: number): Promise { for (let i = 0; i < 8; i++) { if (bytes[i] !== PNG_SIGNATURE[i]) { throw new Error("Not a PNG image"); @@ -382,7 +396,7 @@ async function decodePNG(bytes: Uint8Array): Promise { }; let at = 0; - const pause = createYielder(); + const pause = createYielder(deadline); const unfilter = async ( passWidth: number, passHeight: number, @@ -1123,8 +1137,8 @@ function readExifOrientation( return 1; } -async function decodeJPEG(data: Uint8Array): Promise { - const pause = createYielder(); +async function decodeJPEG(data: Uint8Array, deadline: number): Promise { + const pause = createYielder(deadline); const quantTables: Uint16Array[] = []; const dcTables: HuffmanTable[] = []; const acTables: HuffmanTable[] = []; @@ -2135,8 +2149,9 @@ export async function readImageResponse( // Decoding off the page's thread. /** - * How long an image may take to decode on the worker. Past it, the - * worker is ended, which ends the decode with it, and the image fails. + * How long an image may take to decode. Past it, the worker is ended, + * which ends the decode with it, or the decode here stops at its next + * yield, and the image fails. */ let decodeTimeout = 10_000; // A worker with nothing to do is ended, so it keeps no process alive. @@ -2149,22 +2164,29 @@ interface DecodeWorker { terminate(): void; } -interface Pending { +interface Job { + bytes: Uint8Array; resolve(bitmap: Bitmap): void; reject(error: unknown): void; - timer: ReturnType; } interface WorkerState { worker: DecodeWorker; // Until the worker says it started, a failure means it cannot start. ready: boolean; - pending: Map; idle: ReturnType | null; - // Requests sent before the worker answered, in case it never does. - queued: Map; } +// Images decode one at a time, in the order they ask. A decode's time +// limit then counts its own work and not the line ahead of it, and one +// decode's memory is all the decoding a page holds at once. +const waiting: Job[] = []; +let running: { + job: Job; + id: number; + timer: ReturnType | null; +} | + null = null; let current: WorkerState | null = null; // False for good once a worker fails to start: decoding stays here. let workersWork = true; @@ -2173,52 +2195,95 @@ let nextRequest = 0; /** The module src/decode-worker.ts builds to, which decodes off-thread. */ export function setDecodeWorkerURL(url: URL | null): void { if (current !== null) { - endWorker(current, new Error("The image decoder was replaced")); + endWorker(current); } workerURL = url; workersWork = url !== null; + // An image the ended worker had goes to whatever decodes next. + if (running !== null && running.timer !== null) { + clearTimeout(running.timer); + waiting.unshift(running.job); + running = null; + startNextDecode(); + } } -/** How long a decode may take on the worker before it is ended. */ +/** How long a decode may take before it is stopped. */ export function setDecodeTimeout(ms: number): void { decodeTimeout = ms; } /** * Decode on a worker thread, where the runtime has one, so a large image - * neither stalls the page nor runs past decodeTimeout. Where no worker - * starts, the decode runs here, a slice at a time. + * does not stall the page. Where no worker starts, the decode runs here, + * a slice at a time. Either way it stops past decodeTimeout. */ export function decodeImageOffThread(bytes: Uint8Array): Promise { + return new Promise((resolve, reject) => { + waiting.push({bytes, resolve, reject}); + startNextDecode(); + }); +} + +function startNextDecode(): void { + if (running !== null) { + return; + } + const job = waiting.shift(); + if (job === undefined) { + const idle = current; + if (idle !== null && idle.idle === null) { + idle.idle = setTimeout(() => endWorker(idle), WORKER_IDLE_MS); + (idle.idle as {unref?(): void}).unref?.(); + } + return; + } + const id = nextRequest++; const state = workersWork ? getWorker() : null; if (state === null) { - return decodeImageForPage(bytes); + running = {job, id, timer: null}; + decodeImageForPage(job.bytes, Date.now() + decodeTimeout) + .then(job.resolve, job.reject) + .finally(() => { + running = null; + startNextDecode(); + }); + return; } - const id = nextRequest++; - // A copy the worker can take, whatever buffer the bytes sit in. - const copy = bytes.slice(); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - state.pending.delete(id); - reject( - new Error(`The image took more than ${decodeTimeout} ms to decode`), - ); - // What else this worker held fails with it: whatever stalled it - // may stall the next one too. - endWorker(state, new Error("The image decoder was stopped")); - }, decodeTimeout); - state.pending.set(id, {resolve, reject, timer}); - if (state.idle !== null) { - clearTimeout(state.idle); - state.idle = null; - } - if (!state.ready) { - state.queued.set(id, copy); - } - state.worker.post({id, bytes: copy.buffer}, state.ready - ? [copy.buffer] - : []); - }); + if (state.idle !== null) { + clearTimeout(state.idle); + state.idle = null; + } + const timer = setTimeout(() => { + if (running?.id !== id) { + return; + } + running = null; + job.reject( + new Error(`The image took more than ${decodeTimeout} ms to decode`), + ); + // Ending the worker ends the decode. A new one takes the next image. + endWorker(state); + startNextDecode(); + }, decodeTimeout); + running = {job, id, timer}; + // A copy the worker can take, whatever buffer the bytes sit in. The + // job keeps its own, in case the worker never starts. + const copy = job.bytes.slice(); + state.worker.post({id, bytes: copy.buffer}, [copy.buffer]); +} + +function finishDecode(id: number, settle: (job: Job) => void): void { + if (running?.id !== id) { + return; + } + const {job, timer} = running; + if (timer !== null) { + clearTimeout(timer); + } + running = null; + settle(job); + startNextDecode(); } function getWorker(): WorkerState | null { @@ -2231,7 +2296,7 @@ function getWorker(): WorkerState | null { } let state: WorkerState | null = null; const onMessage = (data: unknown) => { - if (state === null) { + if (state === null || current !== state) { return; } const message = data as { @@ -2246,87 +2311,75 @@ function getWorker(): WorkerState | null { }; if (message.ready) { state.ready = true; - state.queued.clear(); - return; - } - const pending = state.pending.get(message.id!); - if (pending === undefined) { return; } - state.pending.delete(message.id!); - clearTimeout(pending.timer); - if (message.error !== undefined) { - pending.reject(new Error(message.error)); - } else { - pending.resolve({ - width: message.width!, - height: message.height!, - data: new Uint8ClampedArray(message.data!), - naturalWidth: message.naturalWidth, - naturalHeight: message.naturalHeight, - }); - } - if (state.pending.size === 0) { - const idle = state; - state.idle = setTimeout(() => endWorker(idle, null), WORKER_IDLE_MS); - (state.idle as {unref?(): void}).unref?.(); - } + finishDecode(message.id!, (job) => { + if (message.error !== undefined) { + job.reject(new Error(message.error)); + } else { + job.resolve({ + width: message.width!, + height: message.height!, + data: new Uint8ClampedArray(message.data!), + naturalWidth: message.naturalWidth, + naturalHeight: message.naturalHeight, + }); + } + }); }; const onError = (error: unknown) => { - if (state === null) { + if (state === null || current !== state) { return; } - if (!state.ready) { - // It never started: decode here from now on, these included. + const started = state.ready; + endWorker(state); + if (!started) { + // It never started: the image in hand decodes here, and so does + // every one after it. workersWork = false; - const queued = new Map(state.queued); - const pending = new Map(state.pending); - endWorker(state, null); - for (const [id, bytes] of queued) { - const request = pending.get(id)!; - clearTimeout(request.timer); - decodeImageForPage(bytes).then(request.resolve, request.reject); + if (running !== null) { + const {job, timer} = running; + if (timer !== null) { + clearTimeout(timer); + } + running = null; + waiting.unshift(job); } + startNextDecode(); return; } - endWorker( - state, - error instanceof Error ? error : new Error("The image decoder failed"), - ); + // It started and then failed on this image. The image fails, and a + // new worker takes the next. + if (running !== null) { + finishDecode(running.id, (job) => + job.reject( + new Error( + (error as {message?: string} | null)?.message || + "The image decoder failed", + ), + ), + ); + } }; const worker = startWorker(url, onMessage, onError); if (worker === null) { workersWork = false; return null; } - state = { - worker, - ready: false, - pending: new Map(), - idle: null, - queued: new Map(), - }; + state = {worker, ready: false, idle: null}; current = state; return state; } -// Ends the worker. What it still held fails with `error`, or is left -// to the caller when null. -function endWorker(state: WorkerState, error: Error | null): void { +function endWorker(state: WorkerState): void { if (current === state) { current = null; } if (state.idle !== null) { clearTimeout(state.idle); + state.idle = null; } state.worker.terminate(); - if (error !== null) { - for (const pending of state.pending.values()) { - clearTimeout(pending.timer); - pending.reject(error); - } - } - state.pending.clear(); } // A web worker where the runtime has one, Node's worker_threads where it diff --git a/tests/images.test.ts b/tests/images.test.ts index 6812db27..b0b95e07 100644 --- a/tests/images.test.ts +++ b/tests/images.test.ts @@ -466,41 +466,104 @@ function fakeWorker(reply: string): URL { return new URL(`data:text/javascript,${encodeURIComponent(source)}`); } -test("a decode that runs past its time on the worker fails, and the next one starts over", async () => { +// A one-byte image stands for a slow or broken one in these workers. +const STAND_IN = new Uint8Array([0]); + +// How each decode ended: its width, or its error. +function outcomes(decodes: Array>): Promise { + return Promise.all( + decodes.map((decode) => + decode.then((bitmap) => + `${bitmap.width} wide`, (error: Error) => error.message), + ), + ); +} + +const ANSWER = + "port.postMessage({id: data.id, width: 1, height: 1, data: new ArrayBuffer(4)})"; + +test("an image past its time fails alone, and the images behind it decode", async () => { try { - setDecodeTimeout(50); - setDecodeWorkerURL(fakeWorker("")); - await expect(decodeImageOffThread(bytesOf("png-rgb.png"))) - .rejects.toThrow("more than 50 ms"); + setDecodeTimeout(200); + setDecodeWorkerURL( + fakeWorker(`if (data.bytes.byteLength !== 1) ${ANSWER};`), + ); + expect( + await outcomes([ + decodeImageOffThread(STAND_IN), + decodeImageOffThread(bytesOf("png-rgb.png")), + decodeImageOffThread(bytesOf("png-rgb.png")), + ]), + ).toEqual([ + "The image took more than 200 ms to decode", + "1 wide", + "1 wide", + ]); + } finally { setDecodeTimeout(10_000); + setDecodeWorkerURL(null); + } +}); + +test("a worker that fails on an image fails that image alone", async () => { + try { + setDecodeWorkerURL( + fakeWorker( + `if (data.bytes.byteLength === 1) throw new Error("broke"); ${ANSWER};`, + ), + ); + const [before, broken, after] = await outcomes([ + decodeImageOffThread(bytesOf("png-rgb.png")), + decodeImageOffThread(STAND_IN), + decodeImageOffThread(bytesOf("png-rgb.png")), + ]); + expect([before, after]).toEqual(["1 wide", "1 wide"]); + expect(broken).toContain("broke"); setDecodeWorkerURL( fakeWorker("port.postMessage({id: data.id, error: \"from the worker\"})"), ); await expect(decodeImageOffThread(bytesOf("png-rgb.png"))) .rejects.toThrow("from the worker"); } finally { - setDecodeTimeout(10_000); setDecodeWorkerURL(null); } }); -test("where no worker starts, images decode on this thread", async () => { +test("where no worker starts, every image decodes on this thread", async () => { try { setDecodeWorkerURL( new URL( `data:text/javascript,${encodeURIComponent("throw new Error(\"no\");")}`, ), ); - const bitmap = await decodeImageOffThread(bytesOf("png-rgb.png")); - expect([bitmap.width, bitmap.height]).toEqual([16, 16]); - setDecodeWorkerURL(null); - const again = await decodeImageOffThread(bytesOf("png-rgb.png")); - expect(again.width).toBe(16); + const bitmaps = await Promise.all( + [0, 1, 2].map(() => decodeImageOffThread(bytesOf("png-rgb.png"))), + ); + expect(bitmaps.map((bitmap) => [bitmap.width, bitmap.height])) + .toEqual([[16, 16], [16, 16], [16, 16]]); } finally { setDecodeWorkerURL(null); } }); +test("a decode on this thread stops past its time", async () => { + const width = 1500; + const data = new Uint8ClampedArray(width * width * 4); + for (let i = 0; i < data.length; i++) { + data[i] = (i * 2654435761) >>> 24; + } + const png = encodePNG({width, height: width, data}); + try { + setDecodeTimeout(0); + await expect(decodeImageOffThread(png)).rejects.toThrow("too long"); + setDecodeTimeout(10_000); + const bitmap = await decodeImageOffThread(png); + expect(bitmap.naturalWidth ?? bitmap.width).toBe(width); + } finally { + setDecodeTimeout(10_000); + } +}); + test("an image past MAX_KEPT_PIXELS is kept averaged down, with its natural size", () => { const width = 2000; const height = 1000; From 71327d260b41ee2c7624cb1025c47acd6a395d3b Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Sun, 4 Oct 2026 18:07:14 -0400 Subject: [PATCH 17/28] Sample only the grid cells an image lands on, and crop kept images by each side A charactergrid drawImage() larger than the pixel limit used to draw nothing. Now it samples just the part of the source that lands on the grid, so any size draws and the work stays bounded by the grid. createImageBitmap() from an image kept smaller than it is used the width's ratio for both sides, and stretched the edge into what a crop took past it. Each side now uses its own ratio, and past the edge is transparent. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- src/internal/canvas.ts | 79 +++++++++++++++++++++++++++--------------- tests/canvas.test.ts | 41 +++++++++++++++++++--- 2 files changed, 89 insertions(+), 31 deletions(-) diff --git a/src/internal/canvas.ts b/src/internal/canvas.ts index 8353643e..7ca33c3f 100644 --- a/src/internal/canvas.ts +++ b/src/internal/canvas.ts @@ -180,17 +180,36 @@ export function createImageBitmapFrom( return new ImageBitmap(copy); } // From an image kept smaller than it is, the copy is kept as small, - // and measures the crop. - const kept = bitmap.width / naturalWidthOf(bitmap); - const keptWidth = Math.max(1, Math.round(width * kept)); - const keptHeight = Math.max(1, Math.round(height * kept)); - return new ImageBitmap({ - width: keptWidth, - height: keptHeight, - data: sampleBitmap(bitmap, keptWidth, keptHeight, {x, y, width, height}), - naturalWidth: width, - naturalHeight: height, - }); + // and measures the crop. What the crop takes past the image's edge is + // transparent. + const keptX = bitmap.width / naturalWidthOf(bitmap); + const keptY = bitmap.height / naturalHeightOf(bitmap); + const copy = createBitmap( + Math.max(1, Math.round(width * keptX)), + Math.max(1, Math.round(height * keptY)), + ); + const left = Math.max(x, 0); + const top = Math.max(y, 0); + const right = Math.min(x + width, naturalWidthOf(bitmap)); + const bottom = Math.min(y + height, naturalHeightOf(bitmap)); + const toLeft = Math.round((left - x) * keptX); + const toTop = Math.round((top - y) * keptY); + const toWidth = Math.round((right - x) * keptX) - toLeft; + const toHeight = Math.round((bottom - y) * keptY) - toTop; + if (toWidth > 0 && toHeight > 0) { + const part = { + width: toWidth, + height: toHeight, + data: sampleBitmap(bitmap, toWidth, toHeight, { + x: left, + y: top, + width: right - left, + height: bottom - top, + }), + }; + copyRect(part, 0, 0, copy, toLeft, toTop, toWidth, toHeight); + } + return new ImageBitmap({...copy, naturalWidth: width, naturalHeight: height}); } function copyRect( @@ -3247,31 +3266,37 @@ export class CanvasCharacterGridContext { } const cols = Math.round(dw); const rows = Math.round(dh); - if (cols <= 0 || rows <= 0 || cols * rows * 2 > MAX_IMAGE_PIXELS) { + if (cols <= 0 || rows <= 0) { return; } - const pixels = sampleBitmap(bitmap, cols, rows * 2, { - x: sx, - y: sy, - width: sw, - height: sh, - }); const left = Math.round(dx); const top = Math.round(dy); const cells = this[kGridHost].cells(); const {grid} = cells; - for (let row = 0; row < rows; row++) { - for (let col = 0; col < cols; col++) { - const x = left + col; - const y = top + row; - if (x < 0 || y < 0 || x >= cells.cols || y >= cells.rows) { - continue; - } + // Only the cells on the grid are sampled, from the part of the + // source that lands on them. + const firstCol = Math.max(0, -left); + const firstRow = Math.max(0, -top); + const shownCols = Math.min(cols, cells.cols - left) - firstCol; + const shownRows = Math.min(rows, cells.rows - top) - firstRow; + if (shownCols <= 0 || shownRows <= 0) { + return; + } + const pixels = sampleBitmap(bitmap, shownCols, shownRows * 2, { + x: sx + (firstCol / cols) * sw, + y: sy + (firstRow / rows) * sh, + width: (shownCols / cols) * sw, + height: (shownRows / rows) * sh, + }); + for (let row = 0; row < shownRows; row++) { + for (let col = 0; col < shownCols; col++) { + const x = left + firstCol + col; + const y = top + firstRow + row; const index = y * cells.cols + x; const half = halfBlockCell( pixels, - (row * 2 * cols + col) * 4, - ((row * 2 + 1) * cols + col) * 4, + (row * 2 * shownCols + col) * 4, + ((row * 2 + 1) * shownCols + col) * 4, grid.cluster[index] !== 0 && grid.bg[index] !== 0 ? grid.bg[index] : null, diff --git a/tests/canvas.test.ts b/tests/canvas.test.ts index 431c3c2d..8b40fc9a 100644 --- a/tests/canvas.test.ts +++ b/tests/canvas.test.ts @@ -13,6 +13,7 @@ import {expect, test} from "@b9g/libuild/test"; import {TermDOM} from "../src/index.ts"; +import {createImageBitmapFrom, ImageBitmap} from "../src/internal/canvas.ts"; import {decodeImage, encodePNG} from "../src/internal/images.ts"; import {MockProcess, nextFrame, until} from "./test-utils.ts"; @@ -921,15 +922,47 @@ test("ImageData past the pixel limit throws, and so does getImageData", async () dom.dispose(); }); -test("a charactergrid draws nothing for an image sized past the pixel limit", async () => { +test("a charactergrid samples only the cells an image lands on, however large", async () => { const {dom, document} = await mount( "", ); const source = (document.getElementById("src") as HTMLCanvasElement).getContext("2d")!; - source.fillRect(0, 0, 2, 2); + source.fillStyle = "#ff0000"; + source.fillRect(0, 0, 1, 2); + source.fillStyle = "#0000ff"; + source.fillRect(1, 0, 1, 2); const ctx = grid(document); - ctx.drawImage(document.getElementById("src"), 0, 0, 1e6, 1e6); - expect(ctx.getCell(0, 0)).toBeNull(); + ctx.drawImage(document.getElementById("src"), -1e6 + 2, 0, 2e6, 1e6); + expect(ctx.getCell(1, 0).background).toBe("#ff0000"); + expect(ctx.getCell(2, 0).background).toBe("#0000ff"); + expect(ctx.getCell(3, 1).background).toBe("#0000ff"); + dom.dispose(); +}); + +test("createImageBitmap from a kept-down image crops by each side's ratio, and past the edge is clear", async () => { + const data = new Uint8ClampedArray(4 * 2 * 4); + for (let i = 0; i < data.length; i += 4) { + data.set([255, 0, 0, 255], i); + } + const image = new ImageBitmap({ + width: 4, + height: 2, + data, + naturalWidth: 8, + naturalHeight: 8, + }); + const copy = createImageBitmapFrom(image, 4, 4, 8, 8); + expect([copy.width, copy.height]).toEqual([8, 8]); + const {dom, document} = + await mount(""); + const ctx = + (document.getElementById("c") as HTMLCanvasElement).getContext("2d")!; + ctx.imageSmoothingEnabled = false; + ctx.drawImage(copy as unknown as CanvasImageSource, 0, 0); + const alphaAt = + (x: number, y: number) => ctx.getImageData(x, y, 1, 1).data[3]; + expect([alphaAt(1, 1), alphaAt(3, 3)]).toEqual([255, 255]); + expect([alphaAt(6, 1), alphaAt(1, 6), alphaAt(6, 6)]).toEqual([0, 0, 0]); dom.dispose(); }); From 5e91fa79280d730489a339edd5e4028bd68643db Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Sun, 4 Oct 2026 18:07:24 -0400 Subject: [PATCH 18/28] Say what three comments mean without pointing at the past or the future Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- src/internal/dom.ts | 2 +- src/internal/images.ts | 2 +- src/internal/resources.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/internal/dom.ts b/src/internal/dom.ts index 146af320..7bc2b07d 100644 --- a/src/internal/dom.ts +++ b/src/internal/dom.ts @@ -35916,7 +35916,7 @@ function buildWindow(document: Document): Window { } // Images the parser built before their document had a window wait for -// one. The document has one now. +// one, and start here, once it does. function startPendingImages( root: globalThis.Document | globalThis.ShadowRoot, ): void { diff --git a/src/internal/images.ts b/src/internal/images.ts index be2a4db2..879df89b 100644 --- a/src/internal/images.ts +++ b/src/internal/images.ts @@ -92,7 +92,7 @@ function createYielder(deadline: number): () => Promise | undefined { /** * The most pixels a decoded image keeps, 4 MB of them. A terminal shows a * few hundred thousand at most, so a larger image is kept averaged down - * to this, and reports its natural size as before. + * to this, and still reports its full size as its natural size. */ export const MAX_KEPT_PIXELS = 1 << 20; diff --git a/src/internal/resources.ts b/src/internal/resources.ts index 99c27177..347158e5 100644 --- a/src/internal/resources.ts +++ b/src/internal/resources.ts @@ -1,6 +1,6 @@ /** - * Loading what a document's markup asks for: an 's source now, - * stylesheets and the rest later. Each load is a plain Request, which + * Loading what a document's markup asks for, such as an 's + * source. Each load is a plain Request, which * a TermDOM hands to its "request" listeners as a RequestEvent, with * what asked for it. A listener answers with respondWith(), as a * Service Worker answers a fetch event. A request no listener answers From 48439214ac2b53e9e6b5da0ff553b3a2f2bb78e2 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Sun, 4 Oct 2026 18:07:35 -0400 Subject: [PATCH 19/28] Say in the guide that images decode one at a time Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- docs/guides/07-images-and-canvas.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/guides/07-images-and-canvas.md b/docs/guides/07-images-and-canvas.md index 2e23a240..1c0637ee 100644 --- a/docs/guides/07-images-and-canvas.md +++ b/docs/guides/07-images-and-canvas.md @@ -137,10 +137,11 @@ asks for before it allocates, and an image past a limit fails to load: - a PNG's data inflated only as far as its image needs - a JPEG of at most four components and 500 scans -Images decode on a worker thread, so a large one does not stall the -page, and a decode that takes more than 10 seconds is stopped and the -image fails. Where no worker can start, as in a CommonJS build, -images decode on the page's thread a slice at a time. +Images decode on a worker thread, one at a time, so a large one does +not stall the page. A decode that takes more than 10 seconds is +stopped and that image fails; the images behind it still decode. Where +no worker can start, as in a CommonJS build, images decode on the +page's thread a slice at a time, under the same limit. A new `src` cancels the request the old one started. An image whose box would sample more than 2^26 pixels draws nothing, as does a 2D From a1c0b202c202aaa83505e8bfaf27288cdfb63aa2 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Sun, 4 Oct 2026 23:01:17 -0400 Subject: [PATCH 20/28] Give the queued-decode test a limit a fresh worker can start within After the slow image times out, a new worker starts for the next one, and its time counts from when the image is sent. On a loaded machine Deno took longer than 200 ms to start it, so the next image failed too. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- tests/images.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/images.test.ts b/tests/images.test.ts index b0b95e07..b60459f0 100644 --- a/tests/images.test.ts +++ b/tests/images.test.ts @@ -484,7 +484,7 @@ const ANSWER = test("an image past its time fails alone, and the images behind it decode", async () => { try { - setDecodeTimeout(200); + setDecodeTimeout(1000); setDecodeWorkerURL( fakeWorker(`if (data.bytes.byteLength !== 1) ${ANSWER};`), ); @@ -495,7 +495,7 @@ test("an image past its time fails alone, and the images behind it decode", asyn decodeImageOffThread(bytesOf("png-rgb.png")), ]), ).toEqual([ - "The image took more than 200 ms to decode", + "The image took more than 1000 ms to decode", "1 wide", "1 wide", ]); From 1c0e5420a0bf34d305934ae186adbdab244ba197 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 5 Oct 2026 13:16:29 -0400 Subject: [PATCH 21/28] Add SecurityPolicyViolationEvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The event a Content Security Policy fires at an element whose load it blocked (CSP3 §5.1): its documentURI, referrer, blockedURI, effectiveDirective with violatedDirective as its alias, originalPolicy, sourceFile, sample, disposition, statusCode, lineNumber and columnNumber, from an init dictionary whose members all default. A disposition other than "enforce" or "report" is a TypeError. It is on the window like the other event interfaces. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- src/internal/dom.ts | 123 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/src/internal/dom.ts b/src/internal/dom.ts index 7bc2b07d..c095ef61 100644 --- a/src/internal/dom.ts +++ b/src/internal/dom.ts @@ -2655,6 +2655,127 @@ Object.defineProperty(InputEvent.prototype, Symbol.toStringTag, { configurable: true, }); +// WebIDL's USVString: a lone surrogate becomes U+FFFD. +function toUSVString(value: unknown): string { + return String(value).replace( + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?( + eventInitDict, + "A security policy violation event init", + ); + const disposition = String(init.disposition ?? "enforce"); + if (disposition !== "enforce" && disposition !== "report") { + throw new TypeError( + `"${disposition}" is not a security policy violation disposition`, + ); + } + this[kViolation] = { + documentURI: toUSVString(init.documentURI ?? ""), + referrer: toUSVString(init.referrer ?? ""), + blockedURI: toUSVString(init.blockedURI ?? ""), + effectiveDirective: String( + init.effectiveDirective || init.violatedDirective || "", + ), + originalPolicy: String(init.originalPolicy ?? ""), + sourceFile: toUSVString(init.sourceFile ?? ""), + sample: String(init.sample ?? ""), + disposition, + statusCode: toUnsignedShort(init.statusCode ?? 0), + lineNumber: toUnsignedLong(init.lineNumber ?? 0), + columnNumber: toUnsignedLong(init.columnNumber ?? 0), + }; + } + + get documentURI(): string { + return this[kViolation].documentURI; + } + + get referrer(): string { + return this[kViolation].referrer; + } + + get blockedURI(): string { + return this[kViolation].blockedURI; + } + + get effectiveDirective(): string { + return this[kViolation].effectiveDirective; + } + + // A historical alias of effectiveDirective. + get violatedDirective(): string { + return this[kViolation].effectiveDirective; + } + + get originalPolicy(): string { + return this[kViolation].originalPolicy; + } + + get sourceFile(): string { + return this[kViolation].sourceFile; + } + + get sample(): string { + return this[kViolation].sample; + } + + get disposition(): globalThis.SecurityPolicyViolationEventDisposition { + return this[kViolation].disposition; + } + + get statusCode(): number { + return this[kViolation].statusCode; + } + + get lineNumber(): number { + return this[kViolation].lineNumber; + } + + get columnNumber(): number { + return this[kViolation].columnNumber; + } +} + +Object.defineProperty( + SecurityPolicyViolationEvent.prototype, + Symbol.toStringTag, + {value: "SecurityPolicyViolationEvent", configurable: true}, +); + // A transfer here carries text under format names and nothing else. There // is no drag and drop in a terminal and no files to hand over, so // `dropEffect`, `effectAllowed`, `setDragImage()` and `files` exist, @@ -35840,6 +35961,7 @@ const platform = { SVGElement, Screen, ScreenOrientation, + SecurityPolicyViolationEvent, Selection, ShadowRoot, StaticRange, @@ -35983,6 +36105,7 @@ export type { CompositionEvent, TextEvent, InputEvent, + SecurityPolicyViolationEvent, FileList, DataTransferItem, DataTransferItemList, From 5910b9369b1e1f0c52b159b3b838c58441c0d61c Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 5 Oct 2026 13:16:29 -0400 Subject: [PATCH 22/28] Decide what markup loads by a Content Security Policy, not by fetch A TermDOM loaded what its markup asked for only when it was given a fetch, which made the transport the switch: a program that passed one for window.fetch let every image load, and one that passed none saw alt boxes with no reason given. Which loads a document may make is now its Content Security Policy, the contentSecurityPolicy option in a header's syntax, "default-src 'none'" when the program sets none. An loads what img-src allows, or default-src without it. Source expressions match as CSP3 defines them: 'none', 'self', *, schemes, and hosts with wildcards, ports and paths. A page's own in its head narrows the policy and never widens it, since a load must pass every policy. A blocked load fires securitypolicyviolation at its element, and when the program set no policy the first one is reported where uncaught errors go, saying what to set. What the policy allows goes to the "request" listeners as before, then to fetch, now a transport only, the runtime's by default. window.fetch is the program's own, so no policy governs it. The file: rule stands: only a file: document loads files. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- README.md | 5 +- docs/guides/07-images-and-canvas.md | 62 ++++-- examples/images.ts | 10 +- scripts/support-matrix.ts | 2 +- src/index.ts | 51 ++++- src/internal/resources.ts | 284 ++++++++++++++++++++++++-- tests/canvas.test.ts | 2 +- tests/content-security-policy.test.ts | 159 ++++++++++++++ tests/img.test.ts | 4 +- tests/memory.test.ts | 2 +- tests/requests.test.ts | 188 +++++++++++++---- 11 files changed, 680 insertions(+), 89 deletions(-) create mode 100644 tests/content-security-policy.test.ts diff --git a/README.md b/README.md index 178fa87a..3272843e 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,9 @@ setInterval(() => { - **Images and canvas** `` decodes PNG, JPEG, GIF and BMP on a worker, and `` has a 2D context and a `"charactergrid"` context; pixels are drawn two to a cell with half blocks. Images load - only when the program says so: `new TermDOM({fetch})`, or a - `"request"` listener that answers each load. + only what the program's Content Security Policy allows, such as + `new TermDOM({contentSecurityPolicy: "img-src https: data:"})`, and a + `"request"` listener can answer each load. ## How it works diff --git a/docs/guides/07-images-and-canvas.md b/docs/guides/07-images-and-canvas.md index 1c0637ee..be7240ba 100644 --- a/docs/guides/07-images-and-canvas.md +++ b/docs/guides/07-images-and-canvas.md @@ -30,18 +30,44 @@ such as its working directory. ### Loading -Nothing loads unless the program says so. Each load the markup asks for -is a plain `Request`, which the TermDOM dispatches as a `"request"` -event. A listener answers it with `respondWith()`, as a Service Worker -answers a fetch event, and the first answer wins. A request no listener -answers goes to the `fetch` the TermDOM was made with. Without one, it -fails, and the image shows as a box with its `alt` text. +Nothing loads unless the program allows it. Which loads a document may +make is a Content Security Policy, given as the `contentSecurityPolicy` +option in the syntax of a `Content-Security-Policy` header. An `` +loads only what `img-src` allows, or `default-src` when there is no +`img-src`. The default is `default-src 'none'`. ```ts -// Load what a browser would: the runtime's fetch. -const term = new TermDOM({fetch}); +// Images from anywhere on the web, and data: URLs. +new TermDOM({contentSecurityPolicy: "img-src https: data:"}); -// Or decide request by request. +// A mail client: attachments by cid:, nothing remote. +new TermDOM({contentSecurityPolicy: "img-src cid: data:"}); +``` + +Source expressions match as CSP Level 3 defines them: `'none'`, +`'self'`, `*`, a scheme such as `https:` or `cid:`, and a host such as +`https://*.example.com:8443/img/`. `*` covers the network schemes and +the document's own, not `data:` or `cid:`. A host given without a +scheme takes the document's scheme, and for a document not served over +the network, as `about:blank` and `file:` are not, the network schemes. + +A page can narrow the policy with its own +`` in its head, and never +widen it: a load must pass every policy. A load a policy blocks fires +`securitypolicyviolation` at its element, a `SecurityPolicyViolationEvent` +that bubbles to the document and names the URL, the directive and the +policy. The image shows as a box with its `alt` text. When the program +set no policy, the first blocked load is also reported once where +uncaught errors go: the transport's log, or the scrollback at exit. + +A load the policy allows is a plain `Request`, which the TermDOM +dispatches as a `"request"` event. A listener answers it with +`respondWith()`, as a Service Worker answers a fetch event, and the +first answer wins. A request no listener answers goes to the `fetch` +the TermDOM was made with, the runtime's by default. + +```ts +const term = new TermDOM({contentSecurityPolicy: "img-src cid: https:"}); term.addEventListener("request", (event) => { const {request, destination, initiatorType, initiator} = event; if (request.url.startsWith("cid:")) { @@ -60,15 +86,15 @@ fails the request. The request waits for the script that set the source to finish, so a listener added just after the markup sees its images. Only a document -whose own URL is `file:` loads `file:` URLs, whatever would answer -them, so untrusted markup in a page at `about:blank` or `https:` cannot -read local files. Node's `fetch` reads no files, and Bun's and Deno's do, so a -program that wants local files on every runtime answers them itself. - -The page's own `window.fetch` is the `fetch` given, or the runtime's -when there is none, and it resolves a relative URL against the -document's, as a browser's does. It dispatches no event: a script that calls it has -asked for the load. +whose own URL is `file:` loads `file:` URLs, whatever the policy says, +so untrusted markup in a page at `about:blank` or `https:` cannot read +local files. Node's `fetch` reads no files, and Bun's and Deno's do, so +a program that allows local files on every runtime answers them itself. + +The page's own `window.fetch` is the `fetch` given, or the runtime's, +and it resolves a relative URL against the document's, as a browser's +does. No policy governs it and it dispatches no event: in a terminal +it is the program, not untrusted markup, that calls it. PNG, JPEG, GIF and BMP decode: diff --git a/examples/images.ts b/examples/images.ts index bb5409d8..30d1ea9a 100644 --- a/examples/images.ts +++ b/examples/images.ts @@ -9,12 +9,14 @@ import {TermDOM} from "@b9g/termdom"; // to as a data: URL, so the example runs anywhere. const source = typeof process === "undefined" ? undefined : process.argv[2]; -// Images load only through the fetch a TermDOM is given. This one reads -// a file: URL from disk, which Node's own fetch does not, and leaves the -// rest to the runtime. The document's URL is the working directory, so -// a path given on the command line resolves under it. +// Images load only what the Content Security Policy allows: here files, +// data: URLs and the web. The fetch reads a file: URL from disk, which +// Node's own fetch does not, and leaves the rest to the runtime. The +// document's URL is the working directory, so a path given on the +// command line resolves under it. const term = new TermDOM({ url: pathToFileURL(`${process.cwd()}/`).href, + contentSecurityPolicy: "img-src file: data: https:", async fetch(request) { if (request.url.startsWith("file:")) { return new Response(await readFile(new URL(request.url))); diff --git a/scripts/support-matrix.ts b/scripts/support-matrix.ts index 6e63e670..087dbcda 100644 --- a/scripts/support-matrix.ts +++ b/scripts/support-matrix.ts @@ -1067,7 +1067,7 @@ function apiProbe( const terminal = new MockProcess({cols: 40, rows: 12}); const dom = new TermDOM({ transport: terminal.transport, - fetch: (request) => fetch(request), + contentSecurityPolicy: "img-src data: https:", }); try { return {supported: await run(dom), note}; diff --git a/src/index.ts b/src/index.ts index bd55f6f5..39ded163 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,7 +50,9 @@ import {Layout} from "./internal/layout.ts"; import {Painter} from "./internal/painter.ts"; import { answerRequest, + parseContentSecurityPolicies, type RequestEvent, + type RequestPolicy, setResourceLoader, } from "./internal/resources.ts"; @@ -103,14 +105,24 @@ export interface TermDOMOptions { cellSize?: "unit" | "auto" | CellSize; /** - * Where the loads the document's markup asks for go, an 's source - * among them, when no "request" listener answers them, and what the - * page's own `window.fetch` calls. Without one, markup loads nothing, - * and `window.fetch` is the runtime's. + * The Content Security Policy the document's loads are checked against, + * as a `Content-Security-Policy` header would state it: an loads + * only what `img-src`, or `default-src` without it, allows. A page's own + * `` can narrow it, never + * widen it. Defaults to `"default-src 'none'"`, so markup loads nothing + * unless the program allows it. + */ + contentSecurityPolicy?: string; + + /** + * Where an allowed load no "request" listener answers goes, and what the + * page's own `window.fetch` calls. Defaults to the runtime's `fetch`. */ fetch?: (request: Request) => Promise; } +const DEFAULT_CONTENT_SECURITY_POLICY = "default-src 'none'"; + function getCellSizeSource( option: TermDOMOptions["cellSize"], measured: () => Readonly, @@ -275,8 +287,37 @@ export class TermDOM extends EventTarget { init, ), )) as typeof this.window.fetch; + const policy = options.contentSecurityPolicy; + let hinted = false; + const requestPolicy: RequestPolicy = { + policies: parseContentSecurityPolicies( + policy ?? DEFAULT_CONTENT_SECURITY_POLICY, + ), + fetch: pageFetch, + violate: (at, init) => { + const window = this.window as unknown as typeof globalThis; + dispatchAsUserAgent( + at as never, + new window.SecurityPolicyViolationEvent( + "securitypolicyviolation", + init, + ) as never, + ); + // A program that set no policy may not know markup loads + // nothing until it does. + if (policy === undefined && !hinted) { + hinted = true; + reportUncaught( + this, + `TermDOM blocked ${init.blockedURI}, and loads nothing the ` + + "document's markup asks for until a contentSecurityPolicy " + + "allows it, such as \"img-src data: https:\".", + ); + } + }, + }; setResourceLoader(document, (request, context) => - answerRequest(this, document.URL, request, context, options.fetch), + answerRequest(this, document, request, context, requestPolicy), ); this[kLayout] = new Layout( diff --git a/src/internal/resources.ts b/src/internal/resources.ts index 347158e5..716680df 100644 --- a/src/internal/resources.ts +++ b/src/internal/resources.ts @@ -113,26 +113,60 @@ export function loadResource( return loader(request, context); } +/** What a TermDOM decides a document's loads by. */ +export interface RequestPolicy { + // The program's policy, or the default that allows nothing. A page's + // own meta tags are read with each request and can only narrow it. + policies: readonly ContentSecurityPolicy[]; + // Where an allowed request no listener answers goes. + fetch: (request: Request) => Promise; + // Tells of a load a policy blocked. + violate(at: EventTarget, init: SecurityPolicyViolationEventInit): void; +} + /** - * Dispatch the request to `target`'s listeners and settle it: their - * answer, else `fallback`, else a network error. As in a browser, a - * document that is not a file loads no file, about:blank included. + * Settle a load the document's markup asks for. The document's policies + * decide whether it may be made at all. One allowed goes to `target`'s + * "request" listeners, and to `policy.fetch` when none answers. As in a + * browser, a document that is not a file loads no file, about:blank + * included. */ export function answerRequest( target: EventTarget, - documentURL: string, + document: Document, request: Request, context: RequestContext, - fallback: ((request: Request) => Promise) | undefined, + policy: RequestPolicy, ): Promise { - if ( - new URL(request.url).protocol === "file:" && - !documentURL.startsWith("file:") - ) { + const url = new URL(request.url); + const self = new URL(document.URL); + if (url.protocol === "file:" && self.protocol !== "file:") { return Promise.reject( new TypeError("A document that is not a file cannot load a file"), ); } + const blocked = getBlockingDirective([ + ...policy.policies, + ...getDocumentPolicies(document), + ], url, context.destination, self); + if (blocked !== null) { + const at = context.initiator?.isConnected ? context.initiator : document; + policy.violate(at, { + documentURI: document.URL, + blockedURI: request.url, + effectiveDirective: blocked.directive, + originalPolicy: blocked.policy.text, + disposition: "enforce", + bubbles: true, + composed: true, + }); + return Promise.reject( + new TypeError( + `The Content Security Policy directive "${blocked.directive}" ` + + `blocked ${request.url}`, + ), + ); + } const event = new RequestEvent("request", {request, ...context}); event[kDispatching] = true; try { @@ -149,10 +183,234 @@ export function answerRequest( return response; }); } - if (fallback !== undefined) { - return fallback(request); + return policy.fetch(request); +} + +// A policy a page states for itself in its head (HTML §4.2.5.3), which a +// load must also satisfy. +function getDocumentPolicies(document: Document): ContentSecurityPolicy[] { + const head = document.head; + if (head === null) { + return []; + } + const policies: ContentSecurityPolicy[] = []; + for (const meta of head.children) { + if ( + meta.localName === "meta" && + meta.getAttribute("http-equiv")?.toLowerCase() === + "content-security-policy" && + meta.hasAttribute("content") + ) { + policies.push( + ...parseContentSecurityPolicies(meta.getAttribute("content")!), + ); + } + } + return policies; +} + +// --------------------------------------------------------------------------- +// Content Security Policy (CSP3): which loads a document may make. + +/** One parsed policy: each directive's source expressions, by name. */ +export interface ContentSecurityPolicy { + text: string; + directives: Map; +} + +/** + * A serialized policy list, as a header or a meta tag carries it: policies + * separated by commas, directives by semicolons (CSP3 §2.2.1). A directive + * named twice keeps its first value. + */ +export function parseContentSecurityPolicies( + serialized: string, +): ContentSecurityPolicy[] { + const policies: ContentSecurityPolicy[] = []; + for (const text of serialized.split(",")) { + const directives = new Map(); + for (const token of text.split(";")) { + const [name, ...values] = token.trim().split(/[\t\n\f\r ]+/); + if (!name || !/^[a-zA-Z0-9-]+$/.test(name)) { + continue; + } + const key = name.toLowerCase(); + if (!directives.has(key)) { + directives.set(key, values.filter(Boolean)); + } + } + policies.push({text: text.trim(), directives}); + } + return policies; +} + +// The directive that governs a destination, and the one it falls back to. +const FETCH_DIRECTIVES: Partial> = { + image: "img-src", + style: "style-src", + font: "font-src", + audio: "media-src", + video: "media-src", + track: "media-src", +}; + +/** The directive a policy enforces for a load, or null when none applies. */ +function getEffectiveDirective( + policy: ContentSecurityPolicy, + destination: RequestDestination, +): string | null { + const directive = FETCH_DIRECTIVES[destination]; + if (directive !== undefined && policy.directives.has(directive)) { + return directive; + } + return policy.directives.has("default-src") ? "default-src" : null; +} + +/** + * The first policy and directive that block a load of `url` for + * `destination`, from a document at `self`, or null when every policy + * allows it. + */ +export function getBlockingDirective( + policies: readonly ContentSecurityPolicy[], + url: URL, + destination: RequestDestination, + self: URL, +): {policy: ContentSecurityPolicy; directive: string} | null { + for (const policy of policies) { + const directive = getEffectiveDirective(policy, destination); + if ( + directive !== null && + !matchesSourceList(policy.directives.get(directive)!, url, self) + ) { + return {policy, directive}; + } + } + return null; +} + +// CSP3 §6.7.2.6. An empty list, or one that is just 'none', matches nothing. +function matchesSourceList(sources: string[], url: URL, self: URL): boolean { + return sources.some((source) => matchesSource(source, url, self)); +} + +const NETWORK_SCHEMES = new Set(["http", "https", "ws", "wss"]); + +const SCHEME_SOURCE = /^([a-zA-Z][a-zA-Z0-9+.-]*):$/; +const HOST_SOURCE = + /^(?:([a-zA-Z][a-zA-Z0-9+.-]*):\/\/)?(\*|(?:\*\.)?[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*)(?::(\*|\d+))?(\/[^?#]*)?$/; + +function matchesSource(source: string, url: URL, self: URL): boolean { + const scheme = url.protocol.slice(0, -1); + const selfScheme = self.protocol.slice(0, -1); + const lower = source.toLowerCase(); + if (lower === "'self'") { + return matchesSelf(url, self); + } + // Keywords, nonces and hashes govern scripts and styles, never a load. + if (lower.startsWith("'")) { + return false; + } + // A network scheme, or the document's own (§6.7.2.7, step 1). + if (lower === "*") { + return NETWORK_SCHEMES.has(scheme) || scheme === selfScheme; + } + const schemeSource = SCHEME_SOURCE.exec(source); + if (schemeSource !== null) { + return schemePartMatches(schemeSource[1].toLowerCase(), scheme); + } + const hostSource = HOST_SOURCE.exec(source); + if (hostSource === null || url.host === "") { + return false; + } + const [, sourceScheme, host, port, path] = hostSource; + // Without a scheme, the document's own (§6.7.2.7, step 4). A document + // not served over the network, as about:blank and file: are not, has no + // network scheme to lend, so its host sources mean the network ones. + if (sourceScheme !== undefined) { + if (!schemePartMatches(sourceScheme.toLowerCase(), scheme)) { + return false; + } + } else if ( + !schemePartMatches(selfScheme, scheme) && + !(!NETWORK_SCHEMES.has(selfScheme) && NETWORK_SCHEMES.has(scheme)) + ) { + return false; + } + if (!hostPartMatches(host.toLowerCase(), url.hostname.toLowerCase())) { + return false; + } + if (!portPartMatches(port, url)) { + return false; + } + return path === undefined || path === "/" || pathPartMatches(path, url); +} + +// §6.7.2.8: a scheme matches itself, and its secure counterpart. +function schemePartMatches(expression: string, scheme: string): boolean { + return ( + expression === scheme || + (expression === "http" && scheme === "https") || + (expression === "ws" && (scheme === "wss" || scheme === "https")) || + (expression === "wss" && scheme === "https") + ); +} + +// §6.7.2.9: `*.example.com` matches a subdomain, never example.com. +function hostPartMatches(expression: string, host: string): boolean { + if (expression === "*") { + return true; + } + if (expression.startsWith("*.")) { + return host.endsWith(expression.slice(1)); + } + return expression === host; +} + +const DEFAULT_PORTS: Record = { + http: "80", + https: "443", + ws: "80", + wss: "443", +}; + +// §6.7.2.10: no port means the scheme's default, which the URL parser +// leaves empty. Port 80 also matches 443, an upgrade to https. +function portPartMatches(port: string | undefined, url: URL): boolean { + if (port === "*") { + return true; + } + if (port === undefined) { + return url.port === ""; + } + const scheme = url.protocol.slice(0, -1); + const urlPort = url.port === "" ? DEFAULT_PORTS[scheme] : url.port; + return port === urlPort || (port === "80" && urlPort === "443"); +} + +// §6.7.2.11: a path ending in a slash matches what is under it, and any +// other the one resource it names. +function pathPartMatches(path: string, url: URL): boolean { + const expression = decodeURIComponent(path); + const target = decodeURIComponent(url.pathname); + return expression.endsWith("/") + ? target.startsWith(expression) + : target === expression; +} + +// §6.7.2.6: the document's own origin, or its upgrade to https or wss. +function matchesSelf(url: URL, self: URL): boolean { + if (self.origin === "null" || url.origin === "null") { + return false; + } + if (url.origin === self.origin) { + return true; } - return Promise.reject( - new TypeError("Nothing answered the request, and no fetch was given"), + return ( + url.hostname === self.hostname && + ((self.protocol === "http:" && + (url.protocol === "https:" || url.protocol === "wss:")) || + (self.protocol === "ws:" && url.protocol === "wss:")) && + (url.port === self.port || url.port === "") ); } diff --git a/tests/canvas.test.ts b/tests/canvas.test.ts index 8b40fc9a..e4341969 100644 --- a/tests/canvas.test.ts +++ b/tests/canvas.test.ts @@ -53,7 +53,7 @@ async function mount( const terminal = new MockProcess({cols, rows}); const dom = new TermDOM({ transport: terminal.transport, - fetch: (request) => fetch(request), + contentSecurityPolicy: "img-src data: https:", }); dom.document.body.innerHTML = html; await nextFrame(dom); diff --git a/tests/content-security-policy.test.ts b/tests/content-security-policy.test.ts new file mode 100644 index 00000000..3213c539 --- /dev/null +++ b/tests/content-security-policy.test.ts @@ -0,0 +1,159 @@ +/** + * The policy a document's loads are checked against: CSP3's directives, + * source lists and matching, as a program and a page's own meta tags set + * it. + */ +import {expect, test} from "@b9g/libuild/test"; + +import { + getBlockingDirective, + parseContentSecurityPolicies, +} from "../src/internal/resources.ts"; + +function allows( + policy: string, + url: string, + self = "https://mail.example/inbox", + destination: RequestDestination = "image", +): boolean { + return getBlockingDirective( + parseContentSecurityPolicies(policy), + new URL(url), + destination, + new URL(self), + ) === null; +} + +test("img-src governs an image, and default-src stands in for it", () => { + expect(allows("img-src https:", "https://cdn.example/a.png")).toBe(true); + expect(allows("img-src https:", "http://cdn.example/a.png")).toBe(false); + expect(allows("default-src data:", "data:image/png;base64,AAAA")).toBe(true); + expect(allows("default-src data:", "https://cdn.example/a.png")).toBe(false); + // img-src replaces default-src for images; it does not add to it. + expect(allows("default-src *; img-src cid:", "https://cdn.example/a.png")) + .toBe(false); + // A policy that names neither governs nothing an image does. + expect(allows("style-src 'none'", "https://cdn.example/a.png")).toBe(true); +}); + +test("'none', an empty list and keywords match nothing", () => { + expect(allows("img-src 'none'", "data:image/png;base64,AAAA")).toBe(false); + expect(allows("img-src", "https://cdn.example/a.png")).toBe(false); + expect( + allows("img-src 'unsafe-inline' 'nonce-abc'", "https://cdn.example/a.png"), + ) + .toBe(false); +}); + +test("* matches the network schemes and the document's own, not data: or cid:", () => { + expect(allows("img-src *", "https://cdn.example/a.png")).toBe(true); + expect(allows("img-src *", "http://cdn.example/a.png")).toBe(true); + expect(allows("img-src *", "data:image/png;base64,AAAA")).toBe(false); + expect(allows("img-src *", "cid:logo@example.com")).toBe(false); + expect(allows("img-src *", "file:///a.png", "file:///home/me/")).toBe(true); +}); + +test("a scheme matches itself and its secure counterpart", () => { + expect(allows("img-src cid:", "cid:logo@example.com")).toBe(true); + expect(allows("img-src CID:", "cid:logo@example.com")).toBe(true); + expect(allows("img-src http:", "https://cdn.example/a.png")).toBe(true); + expect(allows("img-src https:", "http://cdn.example/a.png")).toBe(false); +}); + +test("a host matches itself, a wildcard its subdomains, and a port or path narrows it", () => { + expect(allows("img-src https://cdn.example", "https://cdn.example/a.png")) + .toBe(true); + expect(allows("img-src https://CDN.example", "https://cdn.example/a.png")) + .toBe(true); + expect(allows("img-src https://*.example", "https://cdn.example/a.png")) + .toBe(true); + expect(allows("img-src https://*.example", "https://example/a.png")) + .toBe(false); + expect( + allows("img-src https://cdn.example", "https://cdn.example:8443/a.png"), + ) + .toBe(false); + expect( + allows("img-src https://cdn.example:*", "https://cdn.example:8443/a.png"), + ) + .toBe(true); + expect( + allows("img-src https://cdn.example/img/", "https://cdn.example/img/a.png"), + ) + .toBe(true); + expect( + allows("img-src https://cdn.example/img/", "https://cdn.example/css/a.png"), + ) + .toBe(false); + expect( + allows( + "img-src https://cdn.example/img/a.png", + "https://cdn.example/img/a.png", + ), + ) + .toBe(true); + expect( + allows( + "img-src https://cdn.example/img/a.png", + "https://cdn.example/img/b.png", + ), + ) + .toBe(false); +}); + +test("a host without a scheme takes the document's, and the network's for a document not served from it", () => { + expect(allows("img-src cdn.example", "https://cdn.example/a.png")).toBe(true); + expect( + allows("img-src cdn.example", "https://cdn.example/a.png", "about:blank"), + ) + .toBe(true); + expect( + allows( + "img-src cdn.example", + "http://cdn.example/a.png", + "https://mail.example/", + ), + ) + .toBe(false); +}); + +test("'self' matches the document's origin and its upgrade, and nothing for an opaque one", () => { + expect(allows("img-src 'self'", "https://mail.example/logo.png")).toBe(true); + expect(allows("img-src 'self'", "https://cdn.example/logo.png")).toBe(false); + expect( + allows( + "img-src 'self'", + "https://mail.example/a.png", + "http://mail.example/", + ), + ) + .toBe(true); + expect(allows("img-src 'self'", "file:///a.png", "file:///home/me/")).toBe( + false, + ); +}); + +test("every policy in a list must allow a load", () => { + expect( + allows( + "img-src https:, img-src https://cdn.example", + "https://cdn.example/a.png", + ), + ) + .toBe(true); + expect( + allows( + "img-src https:, img-src https://cdn.example", + "https://other.example/a.png", + ), + ) + .toBe(false); + const blocked = getBlockingDirective( + parseContentSecurityPolicies("img-src *, default-src 'self'"), + new URL("https://cdn.example/a.png"), + "image", + new URL("https://mail.example/"), + ); + expect([blocked?.directive, blocked?.policy.text]) + .toEqual(["default-src", "default-src 'self'"]); +}); diff --git a/tests/img.test.ts b/tests/img.test.ts index fc2d8c31..d4aaf234 100644 --- a/tests/img.test.ts +++ b/tests/img.test.ts @@ -95,7 +95,7 @@ async function mount( } const dom = new TermDOM({ transport: terminal.transport, - fetch: (request) => fetch(request), + contentSecurityPolicy: "img-src data: https:", }); dom.document.body.innerHTML = html; await nextFrame(dom); @@ -614,7 +614,7 @@ test("with a cell size, an image's pixels and lengths are CSS pixels", async () const dom = new TermDOM({ transport: terminal.transport, cellSize: {width: 7, height: 15}, - fetch: (request) => fetch(request), + contentSecurityPolicy: "img-src data: https:", }); dom.document.body.innerHTML = `` + diff --git a/tests/memory.test.ts b/tests/memory.test.ts index 62d24084..821f8bb2 100644 --- a/tests/memory.test.ts +++ b/tests/memory.test.ts @@ -252,7 +252,7 @@ async function showImages(dom: TermDOM, round: number): Promise { async () => { const dom = new TermDOM({ transport: new MockProcess({cols: 100, rows: 40}).transport, - fetch: (request) => fetch(request), + contentSecurityPolicy: "img-src data: https:", }); for (let round = 0; round < WARM_UP; round++) { await showImages(dom, round); diff --git a/tests/requests.test.ts b/tests/requests.test.ts index 990d0943..cb60d263 100644 --- a/tests/requests.test.ts +++ b/tests/requests.test.ts @@ -1,6 +1,8 @@ /** - * What a document's markup loads goes to the TermDOM as a "request" - * event, then to the `fetch` it was given, and otherwise nowhere. + * What a document's markup loads is first checked against its Content + * Security Policy. What the policy allows goes to the TermDOM as a + * "request" event, then to the `fetch` it was given, the runtime's by + * default. */ import {expect, test} from "@b9g/libuild/test"; @@ -19,6 +21,7 @@ function create( html?: string; url?: string; fetch?: (request: Request) => Promise; + contentSecurityPolicy?: string; } = {}, ): TermDOM { return new TermDOM({ @@ -32,45 +35,64 @@ async function settled(image: HTMLImageElement): Promise { return image.naturalWidth > 0 ? "loaded" : "broken"; } -test("without a listener or a fetch, markup loads nothing", async () => { - const dom = create({ - html: "\"logo\"", +test("without a policy, markup loads nothing, and the block is reported", async () => { + const dom = create({fetch: async () => new Response(PNG)}); + let heard = 0; + dom.addEventListener("request", () => { + heard++; }); - const image = dom.document.querySelector("img")!; - let failed = false; - image.addEventListener("error", () => { - failed = true; + const violations: Array> = []; + dom.document.addEventListener("securitypolicyviolation", (event) => { + violations.push(event as SecurityPolicyViolationEvent); }); + dom.document.body.innerHTML = "\"logo\""; + const image = dom.document.querySelector("img")!; expect(await settled(image)).toBe("broken"); - await until(() => failed); + expect(heard).toBe(0); + expect(violations).toHaveLength(1); + const [violation] = violations; + expect(violation.target).toBe(image); + expect([ + violation.blockedURI, + violation.effectiveDirective, + violation.originalPolicy, + violation.disposition, + violation.isTrusted, + ]).toEqual([ + "https://example.com/a.png", + "default-src", + "default-src 'none'", + "enforce", + true, + ]); dom.dispose(); }); -test("a listener answers what markup asks for, and says what asked", async () => { - const dom = create(); +test("a listener answers what the policy allows, and says what asked", async () => { + const dom = create({contentSecurityPolicy: "img-src cid:"}); const seen: Array> = []; dom.addEventListener("request", (event) => { - const request = event; - seen.push(request); - if (request.request.url.startsWith("cid:")) { - request.respondWith(new Response(PNG)); - } + seen.push(event); + event.respondWith(new Response(PNG)); }); - dom.document.body.innerHTML = ""; - const image = dom.document.getElementById("a") as HTMLImageElement; - expect(await settled(image)).toBe("loaded"); + dom.document.body.innerHTML = + ""; + const a = dom.document.getElementById("a") as HTMLImageElement; + const b = dom.document.getElementById("b") as HTMLImageElement; + expect([await settled(a), await settled(b)]).toEqual(["loaded", "broken"]); expect(seen).toHaveLength(1); expect(seen[0].request!.url).toBe("cid:logo@example.com"); expect(seen[0].request!.headers.get("accept")).toContain("image/png"); expect(seen[0].destination).toBe("image"); expect(seen[0].initiatorType).toBe("img"); - expect(seen[0].initiator).toBe(image); + expect(seen[0].initiator).toBe(a); dom.dispose(); }); -test("a request no listener answers goes to the fetch given", async () => { +test("an allowed request no listener answers goes to the fetch given, or the runtime's", async () => { const asked: string[] = []; const dom = create({ + contentSecurityPolicy: "img-src https:", fetch: async (request) => { asked.push(request.url); return new Response(PNG); @@ -80,14 +102,40 @@ test("a request no listener answers goes to the fetch given", async () => { expect(await settled(dom.document.querySelector("img")!)).toBe("loaded"); expect(asked).toEqual(["https://example.com/a.png"]); dom.dispose(); + + const runtime = create({contentSecurityPolicy: "img-src data:"}); + runtime.document.body.innerHTML = + ``; + expect(await settled(runtime.document.querySelector("img")!)).toBe("loaded"); + runtime.dispose(); +}); + +test("a page's own meta policy narrows the program's, and never widens it", async () => { + const dom = create({contentSecurityPolicy: "img-src https:"}); + dom.addEventListener("request", (event) => { + event.respondWith(new Response(PNG)); + }); + dom.document.head.innerHTML = + ""; + dom.document.body.innerHTML = + "" + + "" + + ""; + const images = ["a", "b", "c"].map((id) => + dom.document.getElementById(id) as HTMLImageElement); + expect(await Promise.all(images.map(settled))) + .toEqual(["loaded", "broken", "broken"]); + dom.dispose(); }); test("Response.error() or a rejection refuses the request", async () => { - const dom = create({fetch: async () => new Response(PNG)}); + const dom = create({ + contentSecurityPolicy: "img-src https:", + fetch: async () => new Response(PNG), + }); dom.addEventListener("request", (event) => { - const request = event; - request.respondWith( - request.request.url.endsWith("a.png") + event.respondWith( + event.request.url.endsWith("a.png") ? Response.error() : Promise.reject(new Error("no")), ); @@ -102,15 +150,14 @@ test("Response.error() or a rejection refuses the request", async () => { }); test("respondWith() is called once, during the event", async () => { - const dom = create(); + const dom = create({contentSecurityPolicy: "img-src https:"}); let late: RequestEvent | null = null; const errors: string[] = []; dom.addEventListener("request", (event) => { - const request = event; - late = request; - request.respondWith(new Response(PNG)); + late = event; + event.respondWith(new Response(PNG)); try { - request.respondWith(new Response(PNG)); + event.respondWith(new Response(PNG)); } catch (error) { errors.push((error as DOMException).name); } @@ -126,7 +173,10 @@ test("respondWith() is called once, during the event", async () => { }); test("a listener added after the constructor's markup hears its images", async () => { - const dom = create({html: ""}); + const dom = create({ + html: "", + contentSecurityPolicy: "img-src cid:", + }); dom.addEventListener("request", (event) => { event.respondWith(new Response(PNG)); }); @@ -134,10 +184,11 @@ test("a listener added after the constructor's markup hears its images", async ( dom.dispose(); }); -test("only a document that is a file loads files, whatever answers", async () => { +test("only a document that is a file loads files, whatever the policy", async () => { let heard = 0; const dom = create({ url: "https://mail.example/message/1", + contentSecurityPolicy: "img-src *", fetch: async () => new Response(PNG), }); dom.addEventListener("request", () => { @@ -148,17 +199,10 @@ test("only a document that is a file loads files, whatever answers", async () => expect(heard).toBe(0); dom.dispose(); - const blank = create({fetch: async () => new Response(PNG)}); - blank.document.body.innerHTML = - ""; - const blankImages = [...blank.document.querySelectorAll("img")]; - expect(await Promise.all(blankImages.map(settled))) - .toEqual(["broken", "broken"]); - blank.dispose(); - const asked: string[] = []; const local = create({ url: "file:///home/me/art/", + contentSecurityPolicy: "img-src file:", fetch: async (request) => { asked.push(request.url); return new Response(PNG); @@ -170,7 +214,7 @@ test("only a document that is a file loads files, whatever answers", async () => local.dispose(); }); -test("window.fetch is the fetch given, and the runtime's without one", async () => { +test("window.fetch is the fetch given, the runtime's without one, and no policy governs it", async () => { const asked: Array<[string, string]> = []; const dom = create({ url: "https://app.example/", @@ -198,3 +242,63 @@ test("a RequestEvent can be made and dispatched by hand", () => { expect(event.initiator).toBeNull(); expect(() => event.respondWith(new Response(""))).toThrow(); }); + +test("a SecurityPolicyViolationEvent takes its init, with violatedDirective an alias", () => { + const dom = create(); + const Violation = (dom.window as unknown as typeof globalThis) + .SecurityPolicyViolationEvent; + const event = new Violation("securitypolicyviolation", { + blockedURI: "https://example.com/a.png", + effectiveDirective: "img-src", + originalPolicy: "img-src 'none'", + statusCode: 200, + }); + expect([ + event.blockedURI, + event.violatedDirective, + event.disposition, + event.statusCode, + String(event), + ]).toEqual([ + "https://example.com/a.png", + "img-src", + "enforce", + 200, + "[object SecurityPolicyViolationEvent]", + ]); + expect(() => + new Violation("x", { + disposition: "bogus" as SecurityPolicyViolationEventDisposition, + })) + .toThrow(TypeError); + dom.dispose(); +}); + +test("a program with no policy hears once that markup loads nothing until one allows it", async () => { + const logged: string[] = []; + const transport = { + ...new MockProcess({cols: 40, rows: 12}).transport, + logError: (text: string) => { + logged.push(text); + return true; + }, + }; + const dom = new TermDOM({transport}); + dom.document.body.innerHTML = + ""; + const images = [...dom.document.querySelectorAll("img")]; + await Promise.all(images.map(settled)); + expect(logged).toHaveLength(1); + expect(logged[0]).toContain("contentSecurityPolicy"); + dom.dispose(); + + const quiet: string[] = []; + const set = new TermDOM({ + transport: {...transport, logError: (text: string) => quiet.push(text) > 0}, + contentSecurityPolicy: "img-src 'none'", + }); + set.document.body.innerHTML = ""; + await settled(set.document.querySelector("img")!); + expect(quiet).toEqual([]); + set.dispose(); +}); From 5cdc0de7f430198b7172db9e143df6218c77ae2e Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 5 Oct 2026 13:20:53 -0400 Subject: [PATCH 23/28] Name the Content Security Policy option csp Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- README.md | 2 +- docs/guides/07-images-and-canvas.md | 13 ++++++------- examples/images.ts | 2 +- scripts/support-matrix.ts | 2 +- src/index.ts | 10 +++++----- tests/canvas.test.ts | 2 +- tests/img.test.ts | 4 ++-- tests/memory.test.ts | 2 +- tests/requests.test.ts | 27 ++++++++++++--------------- 9 files changed, 30 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 3272843e..555bac8a 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ setInterval(() => { worker, and `` has a 2D context and a `"charactergrid"` context; pixels are drawn two to a cell with half blocks. Images load only what the program's Content Security Policy allows, such as - `new TermDOM({contentSecurityPolicy: "img-src https: data:"})`, and a + `new TermDOM({csp: "img-src https: data:"})`, and a `"request"` listener can answer each load. ## How it works diff --git a/docs/guides/07-images-and-canvas.md b/docs/guides/07-images-and-canvas.md index be7240ba..346832dc 100644 --- a/docs/guides/07-images-and-canvas.md +++ b/docs/guides/07-images-and-canvas.md @@ -31,17 +31,16 @@ such as its working directory. ### Loading Nothing loads unless the program allows it. Which loads a document may -make is a Content Security Policy, given as the `contentSecurityPolicy` -option in the syntax of a `Content-Security-Policy` header. An `` -loads only what `img-src` allows, or `default-src` when there is no -`img-src`. The default is `default-src 'none'`. +make is a Content Security Policy, given as the `csp` option in the +syntax of a `Content-Security-Policy` header. An `` loads only what +`img-src` allows, or `default-src` when there is no `img-src`. The default is `default-src 'none'`. ```ts // Images from anywhere on the web, and data: URLs. -new TermDOM({contentSecurityPolicy: "img-src https: data:"}); +new TermDOM({csp: "img-src https: data:"}); // A mail client: attachments by cid:, nothing remote. -new TermDOM({contentSecurityPolicy: "img-src cid: data:"}); +new TermDOM({csp: "img-src cid: data:"}); ``` Source expressions match as CSP Level 3 defines them: `'none'`, @@ -67,7 +66,7 @@ first answer wins. A request no listener answers goes to the `fetch` the TermDOM was made with, the runtime's by default. ```ts -const term = new TermDOM({contentSecurityPolicy: "img-src cid: https:"}); +const term = new TermDOM({csp: "img-src cid: https:"}); term.addEventListener("request", (event) => { const {request, destination, initiatorType, initiator} = event; if (request.url.startsWith("cid:")) { diff --git a/examples/images.ts b/examples/images.ts index 30d1ea9a..d941a428 100644 --- a/examples/images.ts +++ b/examples/images.ts @@ -16,7 +16,7 @@ const source = typeof process === "undefined" ? undefined : process.argv[2]; // command line resolves under it. const term = new TermDOM({ url: pathToFileURL(`${process.cwd()}/`).href, - contentSecurityPolicy: "img-src file: data: https:", + csp: "img-src file: data: https:", async fetch(request) { if (request.url.startsWith("file:")) { return new Response(await readFile(new URL(request.url))); diff --git a/scripts/support-matrix.ts b/scripts/support-matrix.ts index 087dbcda..fe6a75fa 100644 --- a/scripts/support-matrix.ts +++ b/scripts/support-matrix.ts @@ -1067,7 +1067,7 @@ function apiProbe( const terminal = new MockProcess({cols: 40, rows: 12}); const dom = new TermDOM({ transport: terminal.transport, - contentSecurityPolicy: "img-src data: https:", + csp: "img-src data: https:", }); try { return {supported: await run(dom), note}; diff --git a/src/index.ts b/src/index.ts index 39ded163..b7cd36ac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -112,7 +112,7 @@ export interface TermDOMOptions { * widen it. Defaults to `"default-src 'none'"`, so markup loads nothing * unless the program allows it. */ - contentSecurityPolicy?: string; + csp?: string; /** * Where an allowed load no "request" listener answers goes, and what the @@ -287,7 +287,7 @@ export class TermDOM extends EventTarget { init, ), )) as typeof this.window.fetch; - const policy = options.contentSecurityPolicy; + const policy = options.csp; let hinted = false; const requestPolicy: RequestPolicy = { policies: parseContentSecurityPolicies( @@ -309,9 +309,9 @@ export class TermDOM extends EventTarget { hinted = true; reportUncaught( this, - `TermDOM blocked ${init.blockedURI}, and loads nothing the ` + - "document's markup asks for until a contentSecurityPolicy " + - "allows it, such as \"img-src data: https:\".", + `TermDOM blocked ${init.blockedURI}. The document's markup ` + + "loads nothing until a Content Security Policy allows it: " + + "new TermDOM({csp: \"img-src data: https:\"}), for one.", ); } }, diff --git a/tests/canvas.test.ts b/tests/canvas.test.ts index e4341969..8dc6b71c 100644 --- a/tests/canvas.test.ts +++ b/tests/canvas.test.ts @@ -53,7 +53,7 @@ async function mount( const terminal = new MockProcess({cols, rows}); const dom = new TermDOM({ transport: terminal.transport, - contentSecurityPolicy: "img-src data: https:", + csp: "img-src data: https:", }); dom.document.body.innerHTML = html; await nextFrame(dom); diff --git a/tests/img.test.ts b/tests/img.test.ts index d4aaf234..307db4e6 100644 --- a/tests/img.test.ts +++ b/tests/img.test.ts @@ -95,7 +95,7 @@ async function mount( } const dom = new TermDOM({ transport: terminal.transport, - contentSecurityPolicy: "img-src data: https:", + csp: "img-src data: https:", }); dom.document.body.innerHTML = html; await nextFrame(dom); @@ -614,7 +614,7 @@ test("with a cell size, an image's pixels and lengths are CSS pixels", async () const dom = new TermDOM({ transport: terminal.transport, cellSize: {width: 7, height: 15}, - contentSecurityPolicy: "img-src data: https:", + csp: "img-src data: https:", }); dom.document.body.innerHTML = `` + diff --git a/tests/memory.test.ts b/tests/memory.test.ts index 821f8bb2..d860a565 100644 --- a/tests/memory.test.ts +++ b/tests/memory.test.ts @@ -252,7 +252,7 @@ async function showImages(dom: TermDOM, round: number): Promise { async () => { const dom = new TermDOM({ transport: new MockProcess({cols: 100, rows: 40}).transport, - contentSecurityPolicy: "img-src data: https:", + csp: "img-src data: https:", }); for (let round = 0; round < WARM_UP; round++) { await showImages(dom, round); diff --git a/tests/requests.test.ts b/tests/requests.test.ts index cb60d263..fcf056d7 100644 --- a/tests/requests.test.ts +++ b/tests/requests.test.ts @@ -21,7 +21,7 @@ function create( html?: string; url?: string; fetch?: (request: Request) => Promise; - contentSecurityPolicy?: string; + csp?: string; } = {}, ): TermDOM { return new TermDOM({ @@ -69,7 +69,7 @@ test("without a policy, markup loads nothing, and the block is reported", async }); test("a listener answers what the policy allows, and says what asked", async () => { - const dom = create({contentSecurityPolicy: "img-src cid:"}); + const dom = create({csp: "img-src cid:"}); const seen: Array> = []; dom.addEventListener("request", (event) => { seen.push(event); @@ -92,7 +92,7 @@ test("a listener answers what the policy allows, and says what asked", async () test("an allowed request no listener answers goes to the fetch given, or the runtime's", async () => { const asked: string[] = []; const dom = create({ - contentSecurityPolicy: "img-src https:", + csp: "img-src https:", fetch: async (request) => { asked.push(request.url); return new Response(PNG); @@ -103,7 +103,7 @@ test("an allowed request no listener answers goes to the fetch given, or the run expect(asked).toEqual(["https://example.com/a.png"]); dom.dispose(); - const runtime = create({contentSecurityPolicy: "img-src data:"}); + const runtime = create({csp: "img-src data:"}); runtime.document.body.innerHTML = ``; expect(await settled(runtime.document.querySelector("img")!)).toBe("loaded"); @@ -111,7 +111,7 @@ test("an allowed request no listener answers goes to the fetch given, or the run }); test("a page's own meta policy narrows the program's, and never widens it", async () => { - const dom = create({contentSecurityPolicy: "img-src https:"}); + const dom = create({csp: "img-src https:"}); dom.addEventListener("request", (event) => { event.respondWith(new Response(PNG)); }); @@ -130,7 +130,7 @@ test("a page's own meta policy narrows the program's, and never widens it", asyn test("Response.error() or a rejection refuses the request", async () => { const dom = create({ - contentSecurityPolicy: "img-src https:", + csp: "img-src https:", fetch: async () => new Response(PNG), }); dom.addEventListener("request", (event) => { @@ -150,7 +150,7 @@ test("Response.error() or a rejection refuses the request", async () => { }); test("respondWith() is called once, during the event", async () => { - const dom = create({contentSecurityPolicy: "img-src https:"}); + const dom = create({csp: "img-src https:"}); let late: RequestEvent | null = null; const errors: string[] = []; dom.addEventListener("request", (event) => { @@ -173,10 +173,7 @@ test("respondWith() is called once, during the event", async () => { }); test("a listener added after the constructor's markup hears its images", async () => { - const dom = create({ - html: "", - contentSecurityPolicy: "img-src cid:", - }); + const dom = create({html: "", csp: "img-src cid:"}); dom.addEventListener("request", (event) => { event.respondWith(new Response(PNG)); }); @@ -188,7 +185,7 @@ test("only a document that is a file loads files, whatever the policy", async () let heard = 0; const dom = create({ url: "https://mail.example/message/1", - contentSecurityPolicy: "img-src *", + csp: "img-src *", fetch: async () => new Response(PNG), }); dom.addEventListener("request", () => { @@ -202,7 +199,7 @@ test("only a document that is a file loads files, whatever the policy", async () const asked: string[] = []; const local = create({ url: "file:///home/me/art/", - contentSecurityPolicy: "img-src file:", + csp: "img-src file:", fetch: async (request) => { asked.push(request.url); return new Response(PNG); @@ -289,13 +286,13 @@ test("a program with no policy hears once that markup loads nothing until one al const images = [...dom.document.querySelectorAll("img")]; await Promise.all(images.map(settled)); expect(logged).toHaveLength(1); - expect(logged[0]).toContain("contentSecurityPolicy"); + expect(logged[0]).toContain("csp"); dom.dispose(); const quiet: string[] = []; const set = new TermDOM({ transport: {...transport, logError: (text: string) => quiet.push(text) > 0}, - contentSecurityPolicy: "img-src 'none'", + csp: "img-src 'none'", }); set.document.body.innerHTML = ""; await settled(set.document.querySelector("img")!); From 5b5d45aeec2d0750dd4e9f516fed30da595304c6 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 5 Oct 2026 18:27:58 -0400 Subject: [PATCH 24/28] Answer markup's loads through a Service Worker's FetchEvent A load the policy allows now goes to the TermDOM as a "fetch" event, a FetchEvent as Service Workers define it, in place of the "request" event. It carries the spec's fields: request, with its destination, handled, preloadResponse and the client ids. respondWith() answers and stops the other listeners, and preventDefault() without an answer fails the load. The initiator and initiatorType fields are gone; a program that decides image by image changes the markup before it is inserted. The fetch option is gone. A load no listener answers goes to the runtime's fetch, and window.fetch is the runtime's, so a listener is the one way to answer a load. waitUntil() keeps dispose() waiting, and dispose() aborts loads still in flight. Redirects were followed by the runtime and never checked, so an open redirect on an allowed host could reach any host. Each redirect is now checked against the policy again, matched without its path as CSP3 says, and followed on the network without the listeners, as Fetch follows one without its Service Worker. A violation names the URL the document asked for, with no fragment, and a URL that is not HTTP(S) as its scheme. A path in a source expression is compared piece by piece, each piece percent-decoded without throwing, where a malformed escape such as %E0 threw an uncaught URIError and left the image loading for good. 'self' now requires the same port and allows wss:, and ws: matches the http schemes, as CSP3 says. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- README.md | 2 +- docs/guides/07-images-and-canvas.md | 49 +-- examples/images.ts | 19 +- src/index.ts | 62 ++-- src/internal/dom.ts | 5 +- src/internal/resources.ts | 418 +++++++++++++++++++------- tests/content-security-policy.test.ts | 73 +++++ tests/img.test.ts | 4 +- tests/requests.test.ts | 323 ++++++++++++++------ 9 files changed, 700 insertions(+), 255 deletions(-) diff --git a/README.md b/README.md index 555bac8a..4a9ca97a 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ setInterval(() => { context; pixels are drawn two to a cell with half blocks. Images load only what the program's Content Security Policy allows, such as `new TermDOM({csp: "img-src https: data:"})`, and a - `"request"` listener can answer each load. + `"fetch"` listener can answer each load, as a Service Worker does. ## How it works diff --git a/docs/guides/07-images-and-canvas.md b/docs/guides/07-images-and-canvas.md index 346832dc..29696560 100644 --- a/docs/guides/07-images-and-canvas.md +++ b/docs/guides/07-images-and-canvas.md @@ -59,29 +59,40 @@ policy. The image shows as a box with its `alt` text. When the program set no policy, the first blocked load is also reported once where uncaught errors go: the transport's log, or the scrollback at exit. -A load the policy allows is a plain `Request`, which the TermDOM -dispatches as a `"request"` event. A listener answers it with -`respondWith()`, as a Service Worker answers a fetch event, and the -first answer wins. A request no listener answers goes to the `fetch` -the TermDOM was made with, the runtime's by default. +A load the policy allows goes to the TermDOM as a `"fetch"` event, a +`FetchEvent`, as a Service Worker hears it, and to the network when no +listener answers. A listener answers with `respondWith()`, which stops +the other listeners. `Response.error()`, a promise that rejects, or +`preventDefault()` without an answer fails the load. ```ts const term = new TermDOM({csp: "img-src cid: https:"}); -term.addEventListener("request", (event) => { - const {request, destination, initiatorType, initiator} = event; - if (request.url.startsWith("cid:")) { +term.addEventListener("fetch", (event) => { + const {request} = event; + if (request.destination === "image" && request.url.startsWith("cid:")) { event.respondWith(new Response(attachment(request.url))); - } else if (!remoteImagesAllowed(initiator)) { - event.respondWith(Response.error()); } }); ``` -The event carries what the request does not: `destination`, the Fetch -standard's name for what the load is for (`"image"`), `initiatorType`, -Resource Timing's name for what asked (`"img"`), and `initiator`, the -element that asked. `Response.error()`, or a promise that rejects, -fails the request. +The policy comes first, as a page's policy does before its Service +Worker: a load the listener answers must be allowed too, so serving +`cid:` takes both `img-src cid:` and the listener. `request.destination` +says what the load is for (`"image"`). `handled` settles once the load +is answered, and `waitUntil()` keeps `dispose()` waiting for work a +listener goes on with. `dispose()` aborts loads still in flight, which a +listener sees on `request.signal`. + +A redirect, from the network or from a listener's `Response.redirect()`, +is checked against the policy again and followed on the network, not +offered to the listeners, as a browser follows one without its Service +Worker. A violation names the URL the document asked for, never where +it was redirected. A listener that calls `fetch(event.request)` itself +follows redirects on its own, as a Service Worker's fetch does. + +To decide image by image, such as no remote images in quoted mail, +change the markup before it is inserted, as a mail client cleans what +it shows. The request waits for the script that set the source to finish, so a listener added just after the markup sees its images. Only a document @@ -90,10 +101,10 @@ so untrusted markup in a page at `about:blank` or `https:` cannot read local files. Node's `fetch` reads no files, and Bun's and Deno's do, so a program that allows local files on every runtime answers them itself. -The page's own `window.fetch` is the `fetch` given, or the runtime's, -and it resolves a relative URL against the document's, as a browser's -does. No policy governs it and it dispatches no event: in a terminal -it is the program, not untrusted markup, that calls it. +The page's own `window.fetch` is the runtime's, and it resolves a +relative URL against the document's, as a browser's does. No policy +governs it and it dispatches no event: in a terminal it is the program, +not untrusted markup, that calls it. PNG, JPEG, GIF and BMP decode: diff --git a/examples/images.ts b/examples/images.ts index d941a428..e713987a 100644 --- a/examples/images.ts +++ b/examples/images.ts @@ -10,19 +10,20 @@ import {TermDOM} from "@b9g/termdom"; const source = typeof process === "undefined" ? undefined : process.argv[2]; // Images load only what the Content Security Policy allows: here files, -// data: URLs and the web. The fetch reads a file: URL from disk, which -// Node's own fetch does not, and leaves the rest to the runtime. The -// document's URL is the working directory, so a path given on the +// data: URLs and the web. A fetch listener reads a file: URL from disk, +// which Node's own fetch does not, and leaves the rest to the runtime. +// The document's URL is the working directory, so a path given on the // command line resolves under it. const term = new TermDOM({ url: pathToFileURL(`${process.cwd()}/`).href, csp: "img-src file: data: https:", - async fetch(request) { - if (request.url.startsWith("file:")) { - return new Response(await readFile(new URL(request.url))); - } - return fetch(request); - }, +}); +term.addEventListener("fetch", (event) => { + if (event.request.url.startsWith("file:")) { + event.respondWith( + readFile(new URL(event.request.url)).then((bytes) => new Response(bytes)), + ); + } }); term.attach(); const {document} = term; diff --git a/src/index.ts b/src/index.ts index b7cd36ac..f301ea8f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,15 +50,19 @@ import {Layout} from "./internal/layout.ts"; import {Painter} from "./internal/painter.ts"; import { answerRequest, + type FetchEvent, parseContentSecurityPolicies, - type RequestEvent, type RequestPolicy, setResourceLoader, } from "./internal/resources.ts"; export type {CellSize} from "./internal/cssom.ts"; export {transportFromProcess} from "./internal/exchange.ts"; -export {RequestEvent, type RequestEventInit} from "./internal/resources.ts"; +export { + ExtendableEvent, + FetchEvent, + type FetchEventInit, +} from "./internal/resources.ts"; export type { ProcessLike, TTYReadStream, @@ -110,15 +114,10 @@ export interface TermDOMOptions { * only what `img-src`, or `default-src` without it, allows. A page's own * `` can narrow it, never * widen it. Defaults to `"default-src 'none'"`, so markup loads nothing - * unless the program allows it. + * unless the program allows it. The empty string, like a header with + * no directives, sets no policy and allows every load. */ csp?: string; - - /** - * Where an allowed load no "request" listener answers goes, and what the - * page's own `window.fetch` calls. Defaults to the runtime's `fetch`. - */ - fetch?: (request: Request) => Promise; } const DEFAULT_CONTENT_SECURITY_POLICY = "default-src 'none'"; @@ -168,6 +167,8 @@ const kHoverReportingEnabled = Symbol("hoverReportingEnabled"); const kTransport = Symbol("transport"); const kExchange = Symbol("exchange"); const kStaticSibling = Symbol("staticSibling"); +const kLifetimes = Symbol("lifetimes"); +const kLoads = Symbol("loads"); const kAttachBegun = Symbol("attachBegun"); type Lifecycle = "detached" | "attaching" | "attached" | "disposed"; const kLifecycle = Symbol("lifecycle"); @@ -185,7 +186,7 @@ const HELD_ERROR_LIMIT = 50; /** The events a TermDOM dispatches, by type. */ export interface TermDOMEventMap { - request: RequestEvent; + fetch: FetchEvent; } export interface TermDOM { @@ -233,11 +234,15 @@ export interface TermDOM { // The engine behind renderANSI and print, rebuilt when the width, the // cell or the screen's colors change. [kStaticSibling]: TermDOM | null; + // What "fetch" listeners passed to waitUntil(). dispose() waits for it. + [kLifetimes]: Set>; + // Aborts the document's loads when the TermDOM is disposed. + [kLoads]: AbortController; } /** - * A document drawn in a terminal. It is the target of a "request" event - * for each load its markup asks for (see RequestEvent). + * A document drawn in a terminal. It is the target of a "fetch" event for + * each load its markup asks for that its policy allows (see FetchEvent). */ export class TermDOM extends EventTarget { readonly document: Document; @@ -269,6 +274,8 @@ export class TermDOM extends EventTarget { this[kAttachReady] = Promise.resolve(); this[kAttachBegun] = Promise.resolve(); this[kStaticSibling] = null; + this[kLifetimes] = new Set(); + this[kLoads] = new AbortController(); this[kTransport] = options.transport ?? transportFromProcess(); this.window = createWindow( @@ -278,10 +285,9 @@ export class TermDOM extends EventTarget { const document = this.document = this.window.document; // The page's fetch resolves a relative URL against the document, as a - // browser's does, and goes to the fetch given or the runtime's. - const pageFetch = options.fetch ?? ((request: Request) => fetch(request)); - this.window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => - pageFetch( + // browser's does, and goes to the runtime's. + this.window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => + fetch( new Request( typeof input === "string" ? new URL(input, document.baseURI) : input, init, @@ -293,7 +299,12 @@ export class TermDOM extends EventTarget { policies: parseContentSecurityPolicies( policy ?? DEFAULT_CONTENT_SECURITY_POLICY, ), - fetch: pageFetch, + signal: this[kLoads].signal, + extend: (promise) => { + const lifetime = promise.then(() => {}, () => {}); + this[kLifetimes].add(lifetime); + void lifetime.then(() => this[kLifetimes].delete(lifetime)); + }, violate: (at, init) => { const window = this.window as unknown as typeof globalThis; dispatchAsUserAgent( @@ -307,9 +318,13 @@ export class TermDOM extends EventTarget { // nothing until it does. if (policy === undefined && !hinted) { hinted = true; + // A URL that is not HTTP(S) is reported as its scheme alone. + const blocked = init.blockedURI!.includes(":") + ? init.blockedURI + : `a ${init.blockedURI}: URL`; reportUncaught( this, - `TermDOM blocked ${init.blockedURI}. The document's markup ` + + `TermDOM blocked ${blocked}. The document's markup ` + "loads nothing until a Content Security Policy allows it: " + "new TermDOM({csp: \"img-src data: https:\"}), for one.", ); @@ -646,9 +661,10 @@ export class TermDOM extends EventTarget { /** * Hand the terminal back. Resolves when every restore has reached the - * transport. The process transport also restores the shell-critical - * modes synchronously, so exiting without awaiting leaves the shell - * usable. + * transport and what "fetch" listeners passed to waitUntil() has + * settled. Loads still in flight are aborted. The process transport + * also restores the shell-critical modes synchronously, so exiting + * without awaiting leaves the shell usable. */ dispose(): Promise { if (this[kLifecycle] === "disposed") { @@ -657,6 +673,7 @@ export class TermDOM extends EventTarget { const wasAttached = isAttached(this); this[kLifecycle] = "disposed"; + this[kLoads].abort(); setDocumentVisible(this.document, false); // Frames painted in place, so nothing reached the scrollback. Write the @@ -703,7 +720,8 @@ export class TermDOM extends EventTarget { this[kLayout].dispose(); clearHighlights(this.document); disconnectObservers(this.document); - return this[kExchange].flush(); + return Promise.all([this[kExchange].flush(), ...this[kLifetimes]]) + .then(() => {}); } } diff --git a/src/internal/dom.ts b/src/internal/dom.ts index c095ef61..0b3515f6 100644 --- a/src/internal/dom.ts +++ b/src/internal/dom.ts @@ -71,7 +71,7 @@ import { } from "./images.ts"; import type {Layout} from "./layout.ts"; import {linearizeMath} from "./mathml.ts"; -import {loadResource} from "./resources.ts"; +import {loadResource, setRequestDestination} from "./resources.ts"; import { getNextGraphemeBoundary, getPreviousGraphemeBoundary, @@ -16941,9 +16941,8 @@ function updateImageData(image: HTMLImageElement): void { settle("broken", null, error); return; } + setRequestDestination(request, "image"); loadResource(document, request, { - destination: "image", - initiatorType: "img", initiator: image as unknown as globalThis.Element, }) .then((response) => readImageResponse(response)) diff --git a/src/internal/resources.ts b/src/internal/resources.ts index 716680df..1590ba4a 100644 --- a/src/internal/resources.ts +++ b/src/internal/resources.ts @@ -1,65 +1,113 @@ /** - * Loading what a document's markup asks for, such as an 's - * source. Each load is a plain Request, which - * a TermDOM hands to its "request" listeners as a RequestEvent, with - * what asked for it. A listener answers with respondWith(), as a - * Service Worker answers a fetch event. A request no listener answers - * goes to the `fetch` the TermDOM was made with, and without one it - * fails: nothing loads unless the program says so. Only a document that - * is itself a file: URL loads file: URLs. + * Loading what a document's markup asks for, such as an 's source, + * in the order Fetch takes a browser's loads. The document's Content + * Security Policy decides whether a load may be made at all. One it + * allows goes to the TermDOM's "fetch" listeners as a FetchEvent, which + * answer it as a Service Worker does, and to the network when none does. + * Each redirect is checked against the policy again and followed on the + * network. Only a document that is itself a file: URL loads file: URLs. */ -/** What a RequestEvent carries besides the request itself. */ -export interface RequestContext { - // The Fetch standard's request destination: "image" for an . - destination: RequestDestination; - // Resource Timing's initiator type: "img" for an . - initiatorType: string; - // The element that asked, or null. - initiator: Element | null; +const kExtend = Symbol("extend"); +const kPending = Symbol("pending"); + +export interface ExtendableEvent { + // Where waitUntil()'s promises go: the TermDOM that dispatched the + // event. Null for an event a program made, which nothing waits on. + [kExtend]: ((promise: Promise) => void) | null; + [kPending]: number; } -export interface RequestEventInit extends EventInit, Partial { +/** An event whose listeners can ask the TermDOM to wait for their work. */ +export class ExtendableEvent extends Event { + constructor(type: string, init?: EventInit) { + super(type, init); + this[kExtend] = null; + this[kPending] = 0; + } + + /** Keeps the TermDOM's dispose() waiting until `promise` settles. */ + waitUntil(promise: PromiseLike): void { + const lifetime = Promise.resolve(promise); + addLifetimePromise(this, lifetime); + this[kExtend]!(lifetime); + } +} + +// Service Workers §4.4.1: an event is active while it is dispatched, or +// while a promise it was given is pending. +function addLifetimePromise( + event: ExtendableEvent, + promise: Promise, +): void { + if (event[kExtend] === null) { + throw new DOMException( + "Only an event TermDOM dispatches can be extended", + "InvalidStateError", + ); + } + if (event.eventPhase === Event.NONE && event[kPending] === 0) { + throw new DOMException( + "The event is no longer active", + "InvalidStateError", + ); + } + event[kPending]++; + const settled = () => { + queueMicrotask(() => event[kPending]--); + }; + promise.then(settled, settled); +} + +export interface FetchEventInit extends EventInit { request: Request; + preloadResponse?: Promise; + clientId?: string; + resultingClientId?: string; + replacesClientId?: string; + handled?: Promise; } const kResponse = Symbol("response"); -const kDispatching = Symbol("dispatching"); -const kExtensions = Symbol("extensions"); -export interface RequestEvent { +export interface FetchEvent { [kResponse]: Promise | null; - [kDispatching]: boolean; - [kExtensions]: Array>; } /** - * A load a document's markup asks for. A listener that answers it calls - * respondWith() before it returns, and the first answer wins. + * A load the document's markup asks for, dispatched at the TermDOM as a + * Service Worker's fetch event is at its global scope. A listener answers + * it with respondWith(), which stops the other listeners. One that calls + * preventDefault() without answering fails the load. */ -export class RequestEvent extends Event { +export class FetchEvent extends ExtendableEvent { readonly request: Request; - readonly destination: RequestDestination; - readonly initiatorType: string; - readonly initiator: Element | null; + readonly preloadResponse: Promise; + readonly clientId: string; + readonly resultingClientId: string; + readonly replacesClientId: string; + // Settles once the load is answered: rejects with a NetworkError when + // it fails. + readonly handled: Promise; - constructor(type: string, init: RequestEventInit) { + constructor(type: string, init: FetchEventInit) { super(type, init); this.request = init.request; - this.destination = init.destination ?? ""; - this.initiatorType = init.initiatorType ?? "other"; - this.initiator = init.initiator ?? null; + this.preloadResponse = init.preloadResponse ?? Promise.resolve(undefined); + this.clientId = init.clientId ?? ""; + this.resultingClientId = init.resultingClientId ?? ""; + this.replacesClientId = init.replacesClientId ?? ""; + this.handled = init.handled ?? new Promise(() => {}); this[kResponse] = null; - this[kDispatching] = false; - this[kExtensions] = []; } /** - * Answer the request, during the event. Response.error(), or a - * promise that rejects, fails it as a network error does. + * Answer the request, during the event. Response.error(), a promise + * that rejects, or a value that is not a Response fails it as a + * network error does. */ respondWith(response: Response | PromiseLike): void { - if (!this[kDispatching]) { + if (this.eventPhase === Event.NONE) { throw new DOMException( "respondWith() is only called while the event is dispatched", "InvalidStateError", @@ -71,15 +119,37 @@ export class RequestEvent extends Event { "InvalidStateError", ); } - this[kResponse] = Promise.resolve(response); + const answer = Promise.resolve(response); + addLifetimePromise(this, answer); this.stopImmediatePropagation(); + this[kResponse] = answer.then((value) => { + if (!(value instanceof Response)) { + throw new TypeError("respondWith() takes a Response"); + } + return value; + }); } +} - /** Work the listener goes on with after it answers. */ - waitUntil(promise: PromiseLike): void { - // Nothing waits on it, so a rejection must not go unhandled. - this[kExtensions].push(Promise.resolve(promise).catch(() => {})); - } +/** + * Gives a request its Fetch destination, such as "image", which a + * runtime's Request leaves empty. + */ +export function setRequestDestination( + request: Request, + destination: RequestDestination, +): void { + Object.defineProperty(request, "destination", { + value: destination, + enumerable: true, + configurable: true, + }); +} + +/** What a load knows of what asked for it. */ +export interface RequestContext { + // The element that asked, where a policy violation is reported. + initiator: Element | null; } /** How a document's loads are answered, set by the TermDOM that owns it. */ @@ -118,72 +188,161 @@ export interface RequestPolicy { // The program's policy, or the default that allows nothing. A page's // own meta tags are read with each request and can only narrow it. policies: readonly ContentSecurityPolicy[]; - // Where an allowed request no listener answers goes. - fetch: (request: Request) => Promise; // Tells of a load a policy blocked. violate(at: EventTarget, init: SecurityPolicyViolationEventInit): void; + // Takes the work a listener asked the TermDOM to wait for. + extend(promise: Promise): void; + // Aborts every load when the TermDOM is disposed. + signal: AbortSignal; } +const REDIRECT_LIMIT = 20; +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); + /** - * Settle a load the document's markup asks for. The document's policies - * decide whether it may be made at all. One allowed goes to `target`'s - * "request" listeners, and to `policy.fetch` when none answers. As in a - * browser, a document that is not a file loads no file, about:blank - * included. + * Settle a load the document's markup asks for: checked against the + * document's policies, offered to `target`'s "fetch" listeners, and sent + * to the network when none answers. A redirect, from a listener or the + * network, is checked again and followed on the network, as Fetch follows + * one without its Service Worker. */ -export function answerRequest( +export async function answerRequest( target: EventTarget, document: Document, request: Request, context: RequestContext, policy: RequestPolicy, ): Promise { - const url = new URL(request.url); + policy.signal.throwIfAborted(); + const destination = request.destination; + request = new Request(request, { + signal: AbortSignal.any([request.signal, policy.signal]), + }); + setRequestDestination(request, destination); + checkRequest(document, request, new URL(request.url), 0, context, policy); + let response = + await dispatchFetchEvent(target, request, policy) ?? + await fetch(new Request(request, {redirect: "manual"})); + let current = new URL(request.url); + for (let redirects = 1; isRedirect(response); redirects++) { + if (request.redirect !== "follow") { + throw new TypeError(`${request.url} redirects`); + } + if (redirects > REDIRECT_LIMIT) { + throw new TypeError(`${request.url} redirects too many times`); + } + current = new URL(response.headers.get("location")!, current); + await response.body?.cancel().catch(() => {}); + checkRequest(document, request, current, redirects, context, policy); + const next = new Request(current, { + headers: request.headers, + signal: request.signal, + redirect: "manual", + }); + setRequestDestination(next, destination); + response = await fetch(next); + } + if (response.type === "error") { + throw new TypeError("The request was refused"); + } + return response; +} + +function isRedirect(response: Response): boolean { + return REDIRECT_STATUSES.has(response.status) && + response.headers.has("location"); +} + +// Fetch's "should request be blocked", for the request's URL or a URL it +// was redirected to. As in a browser, a document that is not a file loads +// no file, about:blank included. +function checkRequest( + document: Document, + request: Request, + url: URL, + redirects: number, + context: RequestContext, + policy: RequestPolicy, +): void { const self = new URL(document.URL); if (url.protocol === "file:" && self.protocol !== "file:") { - return Promise.reject( - new TypeError("A document that is not a file cannot load a file"), - ); + throw new TypeError("A document that is not a file cannot load a file"); } const blocked = getBlockingDirective([ ...policy.policies, ...getDocumentPolicies(document), - ], url, context.destination, self); - if (blocked !== null) { - const at = context.initiator?.isConnected ? context.initiator : document; - policy.violate(at, { - documentURI: document.URL, - blockedURI: request.url, - effectiveDirective: blocked.directive, - originalPolicy: blocked.policy.text, - disposition: "enforce", - bubbles: true, - composed: true, - }); - return Promise.reject( - new TypeError( - `The Content Security Policy directive "${blocked.directive}" ` + - `blocked ${request.url}`, - ), - ); + ], url, request.destination, self, redirects); + if (blocked === null) { + return; } - const event = new RequestEvent("request", {request, ...context}); - event[kDispatching] = true; - try { - target.dispatchEvent(event); - } finally { - event[kDispatching] = false; + const at = context.initiator?.isConnected ? context.initiator : document; + // The URL the document asked for, never where it was redirected + // (CSP3 §2.4.2), and only as much of it as a report may carry. + policy.violate(at, { + documentURI: stripURLForReport(new URL(document.URL)), + blockedURI: stripURLForReport(new URL(request.url)), + effectiveDirective: blocked.directive, + originalPolicy: blocked.policy.text, + disposition: "enforce", + bubbles: true, + composed: true, + }); + throw new TypeError( + `The Content Security Policy directive "${blocked.directive}" ` + + `blocked ${stripURLForReport(new URL(request.url))}`, + ); +} + +// CSP3 §5.4: a URL that is not HTTP(S) reports as its scheme alone, and +// one that is reports without its fragment and credentials. +function stripURLForReport(url: URL): string { + if (url.protocol !== "http:" && url.protocol !== "https:") { + return url.protocol.slice(0, -1); } + const stripped = new URL(url); + stripped.hash = ""; + stripped.username = ""; + stripped.password = ""; + return stripped.href; +} + +// Service Workers' Handle Fetch, as far as a page's load needs it: the +// listener's response, or null when none answered and the load goes to +// the network. +async function dispatchFetchEvent( + target: EventTarget, + request: Request, + policy: RequestPolicy, +): Promise { + let resolveHandled!: () => void; + let rejectHandled!: (error: DOMException) => void; + const handled = new Promise((resolve, reject) => { + resolveHandled = () => resolve(undefined); + rejectHandled = reject; + }); + handled.catch(() => {}); + const event = new FetchEvent("fetch", {request, handled, cancelable: true}); + event[kExtend] = (promise) => { + policy.extend(promise); + }; + target.dispatchEvent(event); const answer = event[kResponse]; - if (answer !== null) { - return answer.then((response) => { - if (response.type === "error") { - throw new TypeError("The request was refused"); - } - return response; - }); + if (answer === null) { + if (event.defaultPrevented) { + rejectHandled(new DOMException("The load was canceled", "NetworkError")); + throw new TypeError("A fetch listener canceled the load"); + } + resolveHandled(); + return null; + } + try { + const response = await answer; + resolveHandled(); + return response; + } catch (error) { + rejectHandled(new DOMException("The load failed", "NetworkError")); + throw error; } - return policy.fetch(request); } // A policy a page states for itself in its head (HTML §4.2.5.3), which a @@ -269,19 +428,25 @@ function getEffectiveDirective( /** * The first policy and directive that block a load of `url` for * `destination`, from a document at `self`, or null when every policy - * allows it. + * allows it. `redirects` counts the redirects that led to `url`. */ export function getBlockingDirective( policies: readonly ContentSecurityPolicy[], url: URL, destination: RequestDestination, self: URL, + redirects = 0, ): {policy: ContentSecurityPolicy; directive: string} | null { for (const policy of policies) { const directive = getEffectiveDirective(policy, destination); if ( directive !== null && - !matchesSourceList(policy.directives.get(directive)!, url, self) + !matchesSourceList( + policy.directives.get(directive)!, + url, + self, + redirects, + ) ) { return {policy, directive}; } @@ -290,8 +455,13 @@ export function getBlockingDirective( } // CSP3 §6.7.2.6. An empty list, or one that is just 'none', matches nothing. -function matchesSourceList(sources: string[], url: URL, self: URL): boolean { - return sources.some((source) => matchesSource(source, url, self)); +function matchesSourceList( + sources: string[], + url: URL, + self: URL, + redirects: number, +): boolean { + return sources.some((source) => matchesSource(source, url, self, redirects)); } const NETWORK_SCHEMES = new Set(["http", "https", "ws", "wss"]); @@ -300,7 +470,12 @@ const SCHEME_SOURCE = /^([a-zA-Z][a-zA-Z0-9+.-]*):$/; const HOST_SOURCE = /^(?:([a-zA-Z][a-zA-Z0-9+.-]*):\/\/)?(\*|(?:\*\.)?[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*)(?::(\*|\d+))?(\/[^?#]*)?$/; -function matchesSource(source: string, url: URL, self: URL): boolean { +function matchesSource( + source: string, + url: URL, + self: URL, + redirects: number, +): boolean { const scheme = url.protocol.slice(0, -1); const selfScheme = self.protocol.slice(0, -1); const lower = source.toLowerCase(); @@ -343,15 +518,19 @@ function matchesSource(source: string, url: URL, self: URL): boolean { if (!portPartMatches(port, url)) { return false; } - return path === undefined || path === "/" || pathPartMatches(path, url); + // A redirect's target is matched without its path, which would tell + // the page where a cross-origin redirect went (§6.7.2.5). + return path === undefined || redirects > 0 || pathPartMatches(path, url); } -// §6.7.2.8: a scheme matches itself, and its secure counterpart. +// §6.7.2.8: a scheme matches itself and its secure counterpart, and ws +// matches the http schemes too. function schemePartMatches(expression: string, scheme: string): boolean { return ( expression === scheme || (expression === "http" && scheme === "https") || - (expression === "ws" && (scheme === "wss" || scheme === "https")) || + (expression === "ws" && + (scheme === "wss" || scheme === "http" || scheme === "https")) || (expression === "wss" && scheme === "https") ); } @@ -389,16 +568,41 @@ function portPartMatches(port: string | undefined, url: URL): boolean { } // §6.7.2.11: a path ending in a slash matches what is under it, and any -// other the one resource it names. +// other the one resource it names, compared piece by piece once each +// piece is percent-decoded. function pathPartMatches(path: string, url: URL): boolean { - const expression = decodeURIComponent(path); - const target = decodeURIComponent(url.pathname); - return expression.endsWith("/") - ? target.startsWith(expression) - : target === expression; + if (path === "" || (path === "/" && url.pathname === "")) { + return true; + } + const exact = !path.endsWith("/"); + const expected = path.split("/"); + const actual = url.pathname.split("/"); + if (expected.length > actual.length) { + return false; + } + if (exact && expected.length !== actual.length) { + return false; + } + if (!exact) { + expected.pop(); + } + return expected.every((piece, i) => + percentDecode(piece) === percentDecode(actual[i]), + ); } -// §6.7.2.6: the document's own origin, or its upgrade to https or wss. +// The URL Standard's percent-decode, which leaves a malformed sequence as +// it is, comparing as bytes. +function percentDecode(text: string): string { + const bytes = String.fromCharCode(...new TextEncoder().encode(text)); + return bytes.replace(/%([0-9A-Fa-f]{2})/g, (_, hex: string) => + String.fromCharCode(parseInt(hex, 16)), + ); +} + +// §6.7.2.7: the document's own origin, or the same host and port, over +// https or wss, or over http or ws from an http document. The URL parser +// leaves a scheme's default port empty, so two defaults compare equal. function matchesSelf(url: URL, self: URL): boolean { if (self.origin === "null" || url.origin === "null") { return false; @@ -406,11 +610,13 @@ function matchesSelf(url: URL, self: URL): boolean { if (url.origin === self.origin) { return true; } + const scheme = url.protocol.slice(0, -1); + const selfScheme = self.protocol.slice(0, -1); return ( url.hostname === self.hostname && - ((self.protocol === "http:" && - (url.protocol === "https:" || url.protocol === "wss:")) || - (self.protocol === "ws:" && url.protocol === "wss:")) && - (url.port === self.port || url.port === "") + url.port === self.port && + (scheme === "https" || + scheme === "wss" || + (selfScheme === "http" && (scheme === "http" || scheme === "ws"))) ); } diff --git a/tests/content-security-policy.test.ts b/tests/content-security-policy.test.ts index 3213c539..b53e600f 100644 --- a/tests/content-security-policy.test.ts +++ b/tests/content-security-policy.test.ts @@ -58,6 +58,56 @@ test("a scheme matches itself and its secure counterpart", () => { expect(allows("img-src CID:", "cid:logo@example.com")).toBe(true); expect(allows("img-src http:", "https://cdn.example/a.png")).toBe(true); expect(allows("img-src https:", "http://cdn.example/a.png")).toBe(false); + // ws also matches the http schemes. + expect(allows("img-src ws:", "http://cdn.example/a.png")).toBe(true); + expect(allows("img-src ws:", "https://cdn.example/a.png")).toBe(true); + expect(allows("img-src wss:", "http://cdn.example/a.png")).toBe(false); +}); + +test("a path is compared piece by piece, each piece percent-decoded", () => { + expect( + allows( + "img-src https://cdn.example/a%20b/", + "https://cdn.example/a b/c.png", + ), + ) + .toBe(true); + // An encoded slash is part of a piece, not a separator. + expect(allows("img-src https://c.example/a/b", "https://c.example/a%2Fb")) + .toBe(false); + // A malformed escape compares as itself rather than failing. + expect( + allows( + "img-src https://cdn.example/images/", + "https://cdn.example/images/%E0.png", + ), + ) + .toBe(true); + expect(allows("img-src https://cdn.example/%E0", "https://cdn.example/%E0")) + .toBe(true); + expect(allows("img-src https://cdn.example/", "https://cdn.example/a/b.png")) + .toBe(true); +}); + +test("a redirect's target is matched without its path", () => { + const policies = + parseContentSecurityPolicies("img-src https://cdn.example/img/"); + const self = new URL("https://mail.example/"); + const elsewhere = new URL("https://cdn.example/other/a.png"); + expect(getBlockingDirective(policies, elsewhere, "image", self, 0)) + .not.toBeNull(); + expect(getBlockingDirective(policies, elsewhere, "image", self, 1)) + .toBeNull(); + expect( + getBlockingDirective( + policies, + new URL("https://evil.example/img/a.png"), + "image", + self, + 1, + ), + ) + .not.toBeNull(); }); test("a host matches itself, a wildcard its subdomains, and a port or path narrows it", () => { @@ -131,6 +181,29 @@ test("'self' matches the document's origin and its upgrade, and nothing for an o expect(allows("img-src 'self'", "file:///a.png", "file:///home/me/")).toBe( false, ); + // The same host over wss, and the port must agree. + expect( + allows("img-src 'self'", "wss://mail.example/a", "https://mail.example/"), + ) + .toBe(true); + expect( + allows( + "img-src 'self'", + "https://mail.example/a.png", + "http://mail.example:8080/", + ), + ) + .toBe(false); + expect( + allows( + "img-src 'self'", + "https://mail.example:8080/a.png", + "http://mail.example:8080/", + ), + ) + .toBe(true); + // An https document does not allow http from its own host. + expect(allows("img-src 'self'", "http://mail.example/a.png")).toBe(false); }); test("every policy in a list must allow a load", () => { diff --git a/tests/img.test.ts b/tests/img.test.ts index 307db4e6..35da7407 100644 --- a/tests/img.test.ts +++ b/tests/img.test.ts @@ -639,7 +639,7 @@ test("with a cell size, an image's pixels and lengths are CSS pixels", async () test("a new src cancels the request the old one started", async () => { const {dom, document} = await mount(""); const signals: AbortSignal[] = []; - dom.addEventListener("request", (event) => { + dom.addEventListener("fetch", (event) => { const {signal} = event.request; signals.push(signal); event.respondWith( @@ -698,7 +698,7 @@ test("an image that is not showing is a box, sized as the image is", async () => test("an image is a box while it loads", async () => { const terminal = new MockProcess({cols: 40, rows: 12}); const dom = new TermDOM({transport: terminal.transport}); - dom.addEventListener("request", (event) => { + dom.addEventListener("fetch", (event) => { event.respondWith(new Promise(() => {})); }); dom.document.body.innerHTML = diff --git a/tests/requests.test.ts b/tests/requests.test.ts index fcf056d7..e20a7065 100644 --- a/tests/requests.test.ts +++ b/tests/requests.test.ts @@ -1,12 +1,16 @@ /** * What a document's markup loads is first checked against its Content * Security Policy. What the policy allows goes to the TermDOM as a - * "request" event, then to the `fetch` it was given, the runtime's by - * default. + * "fetch" event, as a Service Worker hears it, and to the network when no + * listener answers. A redirect is checked against the policy again and + * followed on the network. */ +import {createServer} from "node:http"; +import type {AddressInfo} from "node:net"; + import {expect, test} from "@b9g/libuild/test"; -import {RequestEvent, TermDOM} from "../src/index.ts"; +import {ExtendableEvent, FetchEvent, TermDOM} from "../src/index.ts"; import {encodePNG} from "../src/internal/images.ts"; import {MockProcess, until} from "./test-utils.ts"; @@ -17,12 +21,7 @@ const PNG = encodePNG({ }) as Uint8Array; function create( - options: { - html?: string; - url?: string; - fetch?: (request: Request) => Promise; - csp?: string; - } = {}, + options: {html?: string; url?: string; csp?: string} = {}, ): TermDOM { return new TermDOM({ transport: new MockProcess({cols: 40, rows: 12}).transport, @@ -35,31 +34,73 @@ async function settled(image: HTMLImageElement): Promise { return image.naturalWidth > 0 ? "loaded" : "broken"; } +interface Server { + origin: string; + port: number; + // Each request the server heard, as "METHOD /path". + heard: string[]; + close(): Promise; +} + +// A server that answers /redirect?to=URL with a 302 to it, and any other +// path with the PNG. +async function serve(): Promise { + const heard: string[] = []; + const server = createServer((request, response) => { + heard.push(`${request.method} ${request.url}`); + const url = new URL(request.url!, "http://localhost"); + if (url.pathname === "/redirect") { + response.writeHead(302, {location: url.searchParams.get("to")!}); + response.end(); + return; + } + response.writeHead(200, {"content-type": "image/png"}); + response.end(PNG); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const {port} = server.address() as AddressInfo; + return { + origin: `http://127.0.0.1:${port}`, + port, + heard, + close: () => new Promise((resolve) => { + server.closeAllConnections?.(); + server.close(() => resolve()); + }), + }; +} + test("without a policy, markup loads nothing, and the block is reported", async () => { - const dom = create({fetch: async () => new Response(PNG)}); + const dom = create(); let heard = 0; - dom.addEventListener("request", () => { + dom.addEventListener("fetch", () => { heard++; }); const violations: Array> = []; dom.document.addEventListener("securitypolicyviolation", (event) => { violations.push(event as SecurityPolicyViolationEvent); }); - dom.document.body.innerHTML = "\"logo\""; - const image = dom.document.querySelector("img")!; - expect(await settled(image)).toBe("broken"); + dom.document.body.innerHTML = + "\"logo\"" + + ""; + const images = [...dom.document.querySelectorAll("img")]; + expect(await Promise.all(images.map(settled))).toEqual(["broken", "broken"]); expect(heard).toBe(0); - expect(violations).toHaveLength(1); + expect(violations).toHaveLength(2); const [violation] = violations; - expect(violation.target).toBe(image); + expect(violation.target).toBe(images[0]); + // A report carries no fragment, and only the scheme of a URL that is + // not HTTP(S). expect([ violation.blockedURI, + violations[1].blockedURI, violation.effectiveDirective, violation.originalPolicy, violation.disposition, violation.isTrusted, ]).toEqual([ "https://example.com/a.png", + "data", "default-src", "default-src 'none'", "enforce", @@ -68,10 +109,10 @@ test("without a policy, markup loads nothing, and the block is reported", async dom.dispose(); }); -test("a listener answers what the policy allows, and says what asked", async () => { +test("a listener answers what the policy allows, as a Service Worker does", async () => { const dom = create({csp: "img-src cid:"}); - const seen: Array> = []; - dom.addEventListener("request", (event) => { + const seen: FetchEvent[] = []; + dom.addEventListener("fetch", (event) => { seen.push(event); event.respondWith(new Response(PNG)); }); @@ -81,38 +122,91 @@ test("a listener answers what the policy allows, and says what asked", async () const b = dom.document.getElementById("b") as HTMLImageElement; expect([await settled(a), await settled(b)]).toEqual(["loaded", "broken"]); expect(seen).toHaveLength(1); - expect(seen[0].request!.url).toBe("cid:logo@example.com"); - expect(seen[0].request!.headers.get("accept")).toContain("image/png"); - expect(seen[0].destination).toBe("image"); - expect(seen[0].initiatorType).toBe("img"); - expect(seen[0].initiator).toBe(a); + const [event] = seen; + expect(event).toBeInstanceOf(FetchEvent); + expect(event).toBeInstanceOf(ExtendableEvent); + expect([ + event.type, + event.cancelable, + event.request.url, + event.request.destination, + event.clientId, + event.resultingClientId, + event.replacesClientId, + ]).toEqual(["fetch", true, "cid:logo@example.com", "image", "", "", ""]); + expect(event.request.headers.get("accept")).toContain("image/png"); + expect(await event.preloadResponse).toBeUndefined(); + expect(await event.handled).toBeUndefined(); dom.dispose(); }); -test("an allowed request no listener answers goes to the fetch given, or the runtime's", async () => { - const asked: string[] = []; - const dom = create({ - csp: "img-src https:", - fetch: async (request) => { - asked.push(request.url); - return new Response(PNG); - }, +test("an allowed request no listener answers goes to the network", async () => { + const server = await serve(); + const dom = create({csp: `img-src ${server.origin}`}); + let heard = 0; + dom.addEventListener("fetch", () => { + heard++; }); - dom.document.body.innerHTML = ""; + dom.document.body.innerHTML = ``; expect(await settled(dom.document.querySelector("img")!)).toBe("loaded"); - expect(asked).toEqual(["https://example.com/a.png"]); + expect(heard).toBe(1); + expect(server.heard).toEqual(["GET /a.png"]); dom.dispose(); + await server.close(); - const runtime = create({csp: "img-src data:"}); - runtime.document.body.innerHTML = + const data = create({csp: "img-src data:"}); + data.document.body.innerHTML = ``; - expect(await settled(runtime.document.querySelector("img")!)).toBe("loaded"); - runtime.dispose(); + expect(await settled(data.document.querySelector("img")!)).toBe("loaded"); + data.dispose(); +}); + +test("a redirect is checked against the policy again, and reported as the URL asked for", async () => { + const server = await serve(); + const elsewhere = `http://localhost:${server.port}/tracker.png`; + const dom = create({csp: `img-src ${server.origin}`}); + const violations: SecurityPolicyViolationEvent[] = []; + dom.document.addEventListener("securitypolicyviolation", (event) => { + violations.push(event as SecurityPolicyViolationEvent); + }); + const away = `${server.origin}/redirect?to=${encodeURIComponent(elsewhere)}`; + const home = `${server.origin}/redirect?to=%2Fhome.png`; + dom.document.body.innerHTML = ``; + const a = dom.document.getElementById("a") as HTMLImageElement; + const b = dom.document.getElementById("b") as HTMLImageElement; + expect([await settled(a), await settled(b)]).toEqual(["broken", "loaded"]); + expect(server.heard).not.toContain("GET /tracker.png"); + expect(server.heard).toContain("GET /home.png"); + expect(violations.map((violation) => violation.blockedURI)).toEqual([away]); + dom.dispose(); + await server.close(); +}); + +test("a listener's redirect is followed on the network, and checked", async () => { + const server = await serve(); + const dom = create({csp: `img-src cid: ${server.origin}`}); + let heard = 0; + dom.addEventListener("fetch", (event) => { + heard++; + const to = event.request.url === "cid:a" + ? `${server.origin}/a.png` + : "https://elsewhere.example/b.png"; + event.respondWith(Response.redirect(to, 302)); + }); + dom.document.body.innerHTML = ""; + const a = dom.document.getElementById("a") as HTMLImageElement; + const b = dom.document.getElementById("b") as HTMLImageElement; + expect([await settled(a), await settled(b)]).toEqual(["loaded", "broken"]); + // The redirect goes to the network, not back to the listener. + expect(heard).toBe(2); + expect(server.heard).toEqual(["GET /a.png"]); + dom.dispose(); + await server.close(); }); test("a page's own meta policy narrows the program's, and never widens it", async () => { const dom = create({csp: "img-src https:"}); - dom.addEventListener("request", (event) => { + dom.addEventListener("fetch", (event) => { event.respondWith(new Response(PNG)); }); dom.document.head.innerHTML = @@ -128,32 +222,41 @@ test("a page's own meta policy narrows the program's, and never widens it", asyn dom.dispose(); }); -test("Response.error() or a rejection refuses the request", async () => { - const dom = create({ - csp: "img-src https:", - fetch: async () => new Response(PNG), - }); - dom.addEventListener("request", (event) => { - event.respondWith( - event.request.url.endsWith("a.png") - ? Response.error() - : Promise.reject(new Error("no")), - ); +test("Response.error(), a rejection, or preventDefault() without an answer fails the load", async () => { + const dom = create({csp: "img-src https:"}); + const handled: Array> = []; + dom.addEventListener("fetch", (event) => { + handled.push(event.handled); + const name = event.request.url.slice(-5); + if (name === "a.png") { + event.respondWith(Response.error()); + } else if (name === "b.png") { + event.respondWith(Promise.reject(new Error("no"))); + } else { + event.preventDefault(); + } }); dom.document.body.innerHTML = "" + - ""; - const a = dom.document.getElementById("a") as HTMLImageElement; - const b = dom.document.getElementById("b") as HTMLImageElement; - expect([await settled(a), await settled(b)]).toEqual(["broken", "broken"]); + "" + + ""; + const images = ["a", "b", "c"].map((id) => + dom.document.getElementById(id) as HTMLImageElement); + expect(await Promise.all(images.map(settled))) + .toEqual(["broken", "broken", "broken"]); + const outcomes = await Promise.all( + handled.map((promise) => + promise.then(() => "resolved", (error: DOMException) => error.name)), + ); + expect(outcomes).toEqual(["resolved", "NetworkError", "NetworkError"]); dom.dispose(); }); -test("respondWith() is called once, during the event", async () => { +test("respondWith() is called once, during the event, and stops the other listeners", async () => { const dom = create({csp: "img-src https:"}); - let late: RequestEvent | null = null; + let late: FetchEvent | null = null; const errors: string[] = []; - dom.addEventListener("request", (event) => { + dom.addEventListener("fetch", (event) => { late = event; event.respondWith(new Response(PNG)); try { @@ -162,7 +265,7 @@ test("respondWith() is called once, during the event", async () => { errors.push((error as DOMException).name); } }); - dom.addEventListener("request", () => { + dom.addEventListener("fetch", () => { errors.push("second listener ran"); }); dom.document.body.innerHTML = ""; @@ -172,9 +275,36 @@ test("respondWith() is called once, during the event", async () => { dom.dispose(); }); +test("waitUntil() keeps dispose() waiting, and dispose() aborts loads in flight", async () => { + const dom = create({csp: "img-src https:"}); + let finish!: () => void; + let signal!: AbortSignal; + dom.addEventListener("fetch", (event) => { + signal = event.request.signal; + event.waitUntil( + new Promise((resolve) => { + finish = resolve; + }), + ); + event.respondWith(new Promise(() => {})); + }); + dom.document.body.innerHTML = ""; + await until(() => signal !== undefined); + let disposed = false; + const disposing = dom.dispose().then(() => { + disposed = true; + }); + expect(signal.aborted).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(disposed).toBe(false); + finish(); + await disposing; + expect(disposed).toBe(true); +}); + test("a listener added after the constructor's markup hears its images", async () => { const dom = create({html: "", csp: "img-src cid:"}); - dom.addEventListener("request", (event) => { + dom.addEventListener("fetch", (event) => { event.respondWith(new Response(PNG)); }); expect(await settled(dom.document.querySelector("img")!)).toBe("loaded"); @@ -183,13 +313,10 @@ test("a listener added after the constructor's markup hears its images", async ( test("only a document that is a file loads files, whatever the policy", async () => { let heard = 0; - const dom = create({ - url: "https://mail.example/message/1", - csp: "img-src *", - fetch: async () => new Response(PNG), - }); - dom.addEventListener("request", () => { + const dom = create({url: "https://mail.example/message/1", csp: "img-src *"}); + dom.addEventListener("fetch", (event) => { heard++; + event.respondWith(new Response(PNG)); }); dom.document.body.innerHTML = ""; expect(await settled(dom.document.querySelector("img")!)).toBe("broken"); @@ -197,13 +324,10 @@ test("only a document that is a file loads files, whatever the policy", async () dom.dispose(); const asked: string[] = []; - const local = create({ - url: "file:///home/me/art/", - csp: "img-src file:", - fetch: async (request) => { - asked.push(request.url); - return new Response(PNG); - }, + const local = create({url: "file:///home/me/art/", csp: "img-src file:"}); + local.addEventListener("fetch", (event) => { + asked.push(event.request.url); + event.respondWith(new Response(PNG)); }); local.document.body.innerHTML = ""; expect(await settled(local.document.querySelector("img")!)).toBe("loaded"); @@ -211,33 +335,46 @@ test("only a document that is a file loads files, whatever the policy", async () local.dispose(); }); -test("window.fetch is the fetch given, the runtime's without one, and no policy governs it", async () => { - const asked: Array<[string, string]> = []; - const dom = create({ - url: "https://app.example/", - fetch: async (request) => { - asked.push([request.method, request.url]); - return new Response("given"); - }, +test("window.fetch is the runtime's, resolved against the document, and no policy or listener governs it", async () => { + const server = await serve(); + const dom = create({url: `${server.origin}/app/`}); + let heard = 0; + dom.addEventListener("fetch", () => { + heard++; }); - const answer = await dom.window.fetch("/data", {method: "POST", body: "x"}); - expect(await answer.text()).toBe("given"); - expect(asked).toEqual([["POST", "https://app.example/data"]]); + const answer = await dom.window.fetch("data", {method: "POST", body: "x"}); + expect(answer.status).toBe(200); + await answer.arrayBuffer(); + expect(server.heard).toEqual(["POST /app/data"]); + expect(heard).toBe(0); dom.dispose(); - - const plain = create(); - const response = await plain.window.fetch("data:text/plain,platform"); - expect(await response.text()).toBe("platform"); - plain.dispose(); + await server.close(); }); -test("a RequestEvent can be made and dispatched by hand", () => { +test("a FetchEvent can be made by hand, and only TermDOM's can be answered or extended", () => { const request = new Request("https://example.com/"); - const event = new RequestEvent("request", {request, destination: "image"}); - expect(event.destination).toBe("image"); - expect(event.initiatorType).toBe("other"); - expect(event.initiator).toBeNull(); + const event = new FetchEvent("fetch", {request}); + expect([ + event.request, + event.clientId, + event.resultingClientId, + event.replacesClientId, + event.cancelable, + ]).toEqual([request, "", "", "", false]); + expect(event.handled).toBeInstanceOf(Promise); expect(() => event.respondWith(new Response(""))).toThrow(); + expect(() => event.waitUntil(Promise.resolve())).toThrow(); + const target = new EventTarget(); + const errors: string[] = []; + target.addEventListener("fetch", (dispatched) => { + try { + (dispatched as FetchEvent).respondWith(new Response("")); + } catch (error) { + errors.push((error as DOMException).name); + } + }); + target.dispatchEvent(new FetchEvent("fetch", {request})); + expect(errors).toEqual(["InvalidStateError"]); }); test("a SecurityPolicyViolationEvent takes its init, with violatedDirective an alias", () => { From 5126716c531328fa7b7f77567080fcd995fb5e26 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 5 Oct 2026 19:06:27 -0400 Subject: [PATCH 25/28] Apply a meta policy on insertion, and drop decodes no longer needed A page's was read from the head on every load, so removing it or changing its content loosened the policy. As HTML says, its policy now holds from when the element is inserted as a child of the head, parsed as one policy with report-uri, frame-ancestors and sandbox dropped, and nothing done to the element later changes it. A decode waiting its turn when its image's source changed, or its TermDOM was disposed, used to run anyway and hold its bytes until then. It is now dropped. The image listens for its TermDOM's disposal only while it loads, so a disposed TermDOM aborts the request and the waiting decode and holds nothing of images it has finished with. A decode's time limit restarts when the worker is ready, so starting the worker does not count against the first image. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- docs/guides/07-images-and-canvas.md | 7 +- src/index.ts | 7 +- src/internal/dom.ts | 34 +++++++++- src/internal/images.ts | 53 ++++++++++++--- src/internal/resources.ts | 99 ++++++++++++++++------------- tests/images.test.ts | 36 +++++++++++ tests/requests.test.ts | 25 ++++++++ 7 files changed, 204 insertions(+), 57 deletions(-) diff --git a/docs/guides/07-images-and-canvas.md b/docs/guides/07-images-and-canvas.md index 29696560..f25be60a 100644 --- a/docs/guides/07-images-and-canvas.md +++ b/docs/guides/07-images-and-canvas.md @@ -52,7 +52,9 @@ the network, as `about:blank` and `file:` are not, the network schemes. A page can narrow the policy with its own `` in its head, and never -widen it: a load must pass every policy. A load a policy blocks fires +widen it: a load must pass every policy. As in a browser, the tag's +policy holds from when it is inserted into the head, and removing the +tag or changing its `content` later changes nothing. A load a policy blocks fires `securitypolicyviolation` at its element, a `SecurityPolicyViolationEvent` that bubbles to the document and names the URL, the directive and the policy. The image shows as a box with its `alt` text. When the program @@ -179,7 +181,8 @@ stopped and that image fails; the images behind it still decode. Where no worker can start, as in a CommonJS build, images decode on the page's thread a slice at a time, under the same limit. -A new `src` cancels the request the old one started. An image whose +A new `src` cancels the request the old one started, and drops its +decode if it is still waiting for its turn. An image whose box would sample more than 2^26 pixels draws nothing, as does a 2D canvas past that size. `ImageData` past it throws a `RangeError`, and a character grid stops at 2^22 cells. diff --git a/src/index.ts b/src/index.ts index f301ea8f..a048994b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -331,8 +331,11 @@ export class TermDOM extends EventTarget { } }, }; - setResourceLoader(document, (request, context) => - answerRequest(this, document, request, context, requestPolicy), + setResourceLoader( + document, + (request, context) => + answerRequest(this, document, request, context, requestPolicy), + this[kLoads].signal, ); this[kLayout] = new Layout( diff --git a/src/internal/dom.ts b/src/internal/dom.ts index 0b3515f6..13e1c40a 100644 --- a/src/internal/dom.ts +++ b/src/internal/dom.ts @@ -71,7 +71,12 @@ import { } from "./images.ts"; import type {Layout} from "./layout.ts"; import {linearizeMath} from "./mathml.ts"; -import {loadResource, setRequestDestination} from "./resources.ts"; +import { + addDocumentPolicy, + getLoadSignal, + loadResource, + setRequestDestination, +} from "./resources.ts"; import { getNextGraphemeBoundary, getPreviousGraphemeBoundary, @@ -16942,11 +16947,18 @@ function updateImageData(image: HTMLImageElement): void { return; } setRequestDestination(request, "image"); + // Disposing the TermDOM aborts the load, the request and a decode + // still waiting for its turn alike. Listened for only while the + // load runs, so the TermDOM holds no image it has finished with. + const ended = getLoadSignal(document); + const onEnded = () => abort.abort(ended!.reason); + ended?.addEventListener("abort", onEnded, {once: true}); loadResource(document, request, { initiator: image as unknown as globalThis.Element, }) .then((response) => readImageResponse(response)) - .then((bytes) => decodeImageOffThread(bytes)) + .then((bytes) => decodeImageOffThread(bytes, abort.signal)) + .finally(() => ended?.removeEventListener("abort", onEnded)) .then( (bitmap) => settle("complete", bitmap), (error) => settle("broken", null, error), @@ -18657,6 +18669,24 @@ class HTMLMetaElement extends HTMLElement { declare content: globalThis.HTMLMetaElement["content"]; declare httpEquiv: globalThis.HTMLMetaElement["httpEquiv"]; declare scheme: globalThis.HTMLMetaElement["scheme"]; + + // HTML §4.2.5.3: the Content-Security-Policy pragma runs when the + // element is inserted into a document as a child of its head. + override [kInsertionSteps](): void { + super[kInsertionSteps](); + const content = this.getAttribute("content"); + if ( + this.isConnected && + this.parentNode?.nodeType === ELEMENT_NODE && + (this.parentNode as Element).localName === "head" && + (this.parentNode as Element).namespaceURI === HTML_NAMESPACE && + this.getAttribute("http-equiv")?.toLowerCase() === + "content-security-policy" && + content + ) { + addDocumentPolicy(this.ownerDocument, content); + } + } } // A run of full blocks for the filled bar and a run of light shade for diff --git a/src/internal/images.ts b/src/internal/images.ts index 879df89b..d560b0a3 100644 --- a/src/internal/images.ts +++ b/src/internal/images.ts @@ -2168,6 +2168,8 @@ interface Job { bytes: Uint8Array; resolve(bitmap: Bitmap): void; reject(error: unknown): void; + // Called when the job leaves the line to decode. + started(): void; } interface WorkerState { @@ -2218,9 +2220,30 @@ export function setDecodeTimeout(ms: number): void { * does not stall the page. Where no worker starts, the decode runs here, * a slice at a time. Either way it stops past decodeTimeout. */ -export function decodeImageOffThread(bytes: Uint8Array): Promise { +export function decodeImageOffThread( + bytes: Uint8Array, + signal?: AbortSignal, +): Promise { return new Promise((resolve, reject) => { - waiting.push({bytes, resolve, reject}); + if (signal?.aborted) { + reject(signal.reason); + return; + } + const job: Job = {bytes, resolve, reject, started: () => {}}; + // A decode that has not started is dropped, with its bytes. One + // that has runs to its end or its time limit. + if (signal !== undefined) { + const onAbort = () => { + const index = waiting.indexOf(job); + if (index !== -1) { + waiting.splice(index, 1); + reject(signal.reason); + } + }; + signal.addEventListener("abort", onAbort, {once: true}); + job.started = () => signal.removeEventListener("abort", onAbort); + } + waiting.push(job); startNextDecode(); }); } @@ -2230,6 +2253,7 @@ function startNextDecode(): void { return; } const job = waiting.shift(); + job?.started(); if (job === undefined) { const idle = current; if (idle !== null && idle.idle === null) { @@ -2254,10 +2278,24 @@ function startNextDecode(): void { clearTimeout(state.idle); state.idle = null; } - const timer = setTimeout(() => { + running = {job, id, timer: startDecodeTimer(id, state)}; + // A copy the worker can take, whatever buffer the bytes sit in. The + // job keeps its own, in case the worker never starts. + const copy = job.bytes.slice(); + state.worker.post({id, bytes: copy.buffer}, [copy.buffer]); +} + +// The running decode's time limit, counted again once the worker is +// ready, so a worker's start does not count against its first image. +function startDecodeTimer( + id: number, + state: WorkerState, +): ReturnType { + return setTimeout(() => { if (running?.id !== id) { return; } + const {job} = running; running = null; job.reject( new Error(`The image took more than ${decodeTimeout} ms to decode`), @@ -2266,11 +2304,6 @@ function startNextDecode(): void { endWorker(state); startNextDecode(); }, decodeTimeout); - running = {job, id, timer}; - // A copy the worker can take, whatever buffer the bytes sit in. The - // job keeps its own, in case the worker never starts. - const copy = job.bytes.slice(); - state.worker.post({id, bytes: copy.buffer}, [copy.buffer]); } function finishDecode(id: number, settle: (job: Job) => void): void { @@ -2311,6 +2344,10 @@ function getWorker(): WorkerState | null { }; if (message.ready) { state.ready = true; + if (running !== null && running.timer !== null) { + clearTimeout(running.timer); + running.timer = startDecodeTimer(running.id, state); + } return; } finishDecode(message.id!, (job) => { diff --git a/src/internal/resources.ts b/src/internal/resources.ts index 1590ba4a..eadafff8 100644 --- a/src/internal/resources.ts +++ b/src/internal/resources.ts @@ -158,13 +158,26 @@ type ResourceLoader = ( context: RequestContext, ) => Promise; -const loaders = new WeakMap(); +const loaders = new WeakMap< + object, + {loader: ResourceLoader; signal: AbortSignal} +>(); +/** `signal` aborts when the TermDOM that owns the document is disposed. */ export function setResourceLoader( document: object, loader: ResourceLoader, + signal: AbortSignal, ): void { - loaders.set(document, loader); + loaders.set(document, {loader, signal}); +} + +/** + * Aborts when the document's loads end for good, or null for a document + * no TermDOM owns. + */ +export function getLoadSignal(document: object): AbortSignal | null { + return loaders.get(document)?.signal ?? null; } /** @@ -176,11 +189,11 @@ export function loadResource( request: Request, context: RequestContext, ): Promise { - const loader = loaders.get(document); - if (loader === undefined) { + const entry = loaders.get(document); + if (entry === undefined) { return Promise.reject(new TypeError("This document loads nothing")); } - return loader(request, context); + return entry.loader(request, context); } /** What a TermDOM decides a document's loads by. */ @@ -192,7 +205,7 @@ export interface RequestPolicy { violate(at: EventTarget, init: SecurityPolicyViolationEventInit): void; // Takes the work a listener asked the TermDOM to wait for. extend(promise: Promise): void; - // Aborts every load when the TermDOM is disposed. + // Aborted when the TermDOM is disposed, after which nothing loads. signal: AbortSignal; } @@ -215,10 +228,6 @@ export async function answerRequest( ): Promise { policy.signal.throwIfAborted(); const destination = request.destination; - request = new Request(request, { - signal: AbortSignal.any([request.signal, policy.signal]), - }); - setRequestDestination(request, destination); checkRequest(document, request, new URL(request.url), 0, context, policy); let response = await dispatchFetchEvent(target, request, policy) ?? @@ -345,27 +354,30 @@ async function dispatchFetchEvent( } } -// A policy a page states for itself in its head (HTML §4.2.5.3), which a -// load must also satisfy. -function getDocumentPolicies(document: Document): ContentSecurityPolicy[] { - const head = document.head; - if (head === null) { - return []; +// The policies a page stated for itself with in its head. +const documentPolicies = new WeakMap(); + +/** + * A `` inserted as a child of + * a head (HTML §4.2.5.3): its policy holds for the document from then on, + * whatever later becomes of the element. A meta policy cannot report, + * frame or sandbox, so those directives are dropped. + */ +export function addDocumentPolicy(document: object, content: string): void { + const policy = parseContentSecurityPolicy(content); + for (const directive of ["report-uri", "frame-ancestors", "sandbox"]) { + policy.directives.delete(directive); } - const policies: ContentSecurityPolicy[] = []; - for (const meta of head.children) { - if ( - meta.localName === "meta" && - meta.getAttribute("http-equiv")?.toLowerCase() === - "content-security-policy" && - meta.hasAttribute("content") - ) { - policies.push( - ...parseContentSecurityPolicies(meta.getAttribute("content")!), - ); - } + const policies = documentPolicies.get(document); + if (policies === undefined) { + documentPolicies.set(document, [policy]); + } else { + policies.push(policy); } - return policies; +} + +function getDocumentPolicies(document: object): ContentSecurityPolicy[] { + return documentPolicies.get(document) ?? []; } // --------------------------------------------------------------------------- @@ -385,22 +397,23 @@ export interface ContentSecurityPolicy { export function parseContentSecurityPolicies( serialized: string, ): ContentSecurityPolicy[] { - const policies: ContentSecurityPolicy[] = []; - for (const text of serialized.split(",")) { - const directives = new Map(); - for (const token of text.split(";")) { - const [name, ...values] = token.trim().split(/[\t\n\f\r ]+/); - if (!name || !/^[a-zA-Z0-9-]+$/.test(name)) { - continue; - } - const key = name.toLowerCase(); - if (!directives.has(key)) { - directives.set(key, values.filter(Boolean)); - } + return serialized.split(",").map(parseContentSecurityPolicy); +} + +/** One serialized policy, as a meta tag carries it (CSP3 §2.2.1). */ +function parseContentSecurityPolicy(text: string): ContentSecurityPolicy { + const directives = new Map(); + for (const token of text.split(";")) { + const [name, ...values] = token.trim().split(/[\t\n\f\r ]+/); + if (!name || !/^[a-zA-Z0-9-]+$/.test(name)) { + continue; + } + const key = name.toLowerCase(); + if (!directives.has(key)) { + directives.set(key, values.filter(Boolean)); } - policies.push({text: text.trim(), directives}); } - return policies; + return {text: text.trim(), directives}; } // The directive that governs a destination, and the one it falls back to. diff --git a/tests/images.test.ts b/tests/images.test.ts index b60459f0..221b4088 100644 --- a/tests/images.test.ts +++ b/tests/images.test.ts @@ -505,6 +505,42 @@ test("an image past its time fails alone, and the images behind it decode", asyn } }); +test("a decode aborted while it waits is dropped, and the line moves on", async () => { + try { + setDecodeTimeout(1000); + setDecodeWorkerURL( + fakeWorker(`if (data.bytes.byteLength !== 1) ${ANSWER};`), + ); + const abort = new AbortController(); + const started = Date.now(); + let droppedAfter = -1; + // The stand-in, ahead of it, holds the worker until its time is up. + const stalled = decodeImageOffThread(STAND_IN); + const dropped = decodeImageOffThread(bytesOf("png-rgb.png"), abort.signal) + .finally(() => { + droppedAfter = Date.now() - started; + }); + const decodes = [ + stalled, + dropped, + decodeImageOffThread(bytesOf("png-rgb.png")), + ]; + abort.abort(new Error("The source changed")); + expect(await outcomes(decodes)).toEqual([ + "The image took more than 1000 ms to decode", + "The source changed", + "1 wide", + ]); + expect(droppedAfter).toBeLessThan(500); + await expect( + decodeImageOffThread(bytesOf("png-rgb.png"), abort.signal), + ).rejects.toThrow("The source changed"); + } finally { + setDecodeTimeout(10_000); + setDecodeWorkerURL(null); + } +}); + test("a worker that fails on an image fails that image alone", async () => { try { setDecodeWorkerURL( diff --git a/tests/requests.test.ts b/tests/requests.test.ts index e20a7065..4456094a 100644 --- a/tests/requests.test.ts +++ b/tests/requests.test.ts @@ -222,6 +222,31 @@ test("a page's own meta policy narrows the program's, and never widens it", asyn dom.dispose(); }); +test("a meta policy holds from its insertion into the head, whatever becomes of the element", async () => { + const dom = create({csp: "img-src https:"}); + dom.addEventListener("fetch", (event) => { + event.respondWith(new Response(PNG)); + }); + const load = async (src: string): Promise => { + const image = dom.document.createElement("img"); + image.src = src; + dom.document.body.append(image); + return settled(image); + }; + const meta = dom.document.createElement("meta"); + meta.httpEquiv = "Content-Security-Policy"; + meta.content = "img-src https://cdn.example"; + dom.document.body.append(meta.cloneNode()); + expect(await load("https://other.example/a.png")).toBe("loaded"); + dom.document.head.append(meta); + expect(await load("https://other.example/b.png")).toBe("broken"); + meta.content = "img-src https:"; + meta.remove(); + expect(await load("https://other.example/c.png")).toBe("broken"); + expect(await load("https://cdn.example/d.png")).toBe("loaded"); + dom.dispose(); +}); + test("Response.error(), a rejection, or preventDefault() without an answer fails the load", async () => { const dom = create({csp: "img-src https:"}); const handled: Array> = []; From 354e4742036502453c099c1e02c517b1e58d0c28 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Mon, 5 Oct 2026 20:09:53 -0400 Subject: [PATCH 26/28] Check a listener's response where it ended up, and end loads on abort A redirect could go to a file, data: or any other URL, since only the policy was checked, and Bun's and Deno's fetch read files: a remote server could send a file: page's image to a local file. As Fetch says, a redirect now goes only to another HTTP(S) URL. A listener that answered with fetch(event.request) let the runtime follow redirects with no check, so a redirect reached any host. As a browser checks a Service Worker's response against the page's policy, a listener's response that came from another URL is checked there, as a redirect target when it was redirected. A load waiting on a listener's answer never ended when its source changed or the TermDOM was disposed, and kept a listener on the TermDOM's disposal meanwhile. The wait now ends when the request is aborted, and handled rejects with a NetworkError. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- docs/guides/07-images-and-canvas.md | 13 +++- src/internal/resources.ts | 64 ++++++++++++++-- tests/requests.test.ts | 109 +++++++++++++++++++++++++++- 3 files changed, 174 insertions(+), 12 deletions(-) diff --git a/docs/guides/07-images-and-canvas.md b/docs/guides/07-images-and-canvas.md index f25be60a..932416a7 100644 --- a/docs/guides/07-images-and-canvas.md +++ b/docs/guides/07-images-and-canvas.md @@ -83,14 +83,19 @@ Worker: a load the listener answers must be allowed too, so serving says what the load is for (`"image"`). `handled` settles once the load is answered, and `waitUntil()` keeps `dispose()` waiting for work a listener goes on with. `dispose()` aborts loads still in flight, which a -listener sees on `request.signal`. +listener sees on `request.signal`. A load waiting on a listener's answer +ends when its `src` changes or the TermDOM is disposed, whether or not +the answer ever comes. A redirect, from the network or from a listener's `Response.redirect()`, is checked against the policy again and followed on the network, not offered to the listeners, as a browser follows one without its Service -Worker. A violation names the URL the document asked for, never where -it was redirected. A listener that calls `fetch(event.request)` itself -follows redirects on its own, as a Service Worker's fetch does. +Worker. A redirect goes only to another `http:` or `https:` URL, and at +most 20 are followed. A listener that answers with `fetch(event.request)` +follows redirects on its own, and its response is checked against the +policy where it ended up, as a browser checks a Service Worker's +response. A violation names the URL the document asked for, never where +it was redirected. To decide image by image, such as no remote images in quoted mail, change the markup before it is inserted, as a mail client cleans what diff --git a/src/internal/resources.ts b/src/internal/resources.ts index eadafff8..a07d4029 100644 --- a/src/internal/resources.ts +++ b/src/internal/resources.ts @@ -229,11 +229,34 @@ export async function answerRequest( policy.signal.throwIfAborted(); const destination = request.destination; checkRequest(document, request, new URL(request.url), 0, context, policy); - let response = - await dispatchFetchEvent(target, request, policy) ?? - await fetch(new Request(request, {redirect: "manual"})); - let current = new URL(request.url); + let response = await dispatchFetchEvent(target, request, policy); + if (response === null) { + response = await fetch(new Request(request, {redirect: "manual"})); + } else if (response.url !== "" && response.url !== request.url) { + // A listener's response that came from somewhere, such as its own + // fetch(event.request), is checked where it ended up, as Fetch checks + // a Service Worker's response against the page's policy. + const final = new URL(response.url); + try { + if (response.redirected) { + checkRedirectScheme(request, final); + } + checkRequest( + document, + request, + final, + response.redirected ? 1 : 0, + context, + policy, + ); + } catch (error) { + await response.body?.cancel().catch(() => {}); + throw error; + } + } + let current = new URL(response.url || request.url); for (let redirects = 1; isRedirect(response); redirects++) { + await response.body?.cancel().catch(() => {}); if (request.redirect !== "follow") { throw new TypeError(`${request.url} redirects`); } @@ -241,7 +264,7 @@ export async function answerRequest( throw new TypeError(`${request.url} redirects too many times`); } current = new URL(response.headers.get("location")!, current); - await response.body?.cancel().catch(() => {}); + checkRedirectScheme(request, current); checkRequest(document, request, current, redirects, context, policy); const next = new Request(current, { headers: request.headers, @@ -257,6 +280,16 @@ export async function answerRequest( return response; } +// Fetch's "HTTP-redirect fetch": a redirect goes only to another HTTP(S) +// URL, never to a file, data: or anything the network does not serve. +function checkRedirectScheme(request: Request, url: URL): void { + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new TypeError( + `${request.url} redirects to a URL that is not HTTP(S)`, + ); + } +} + function isRedirect(response: Response): boolean { return REDIRECT_STATUSES.has(response.status) && response.headers.has("location"); @@ -345,7 +378,7 @@ async function dispatchFetchEvent( return null; } try { - const response = await answer; + const response = await whileNotAborted(answer, request.signal); resolveHandled(); return response; } catch (error) { @@ -354,6 +387,25 @@ async function dispatchFetchEvent( } } +// An answer still pending when its request is aborted, by a new source or +// the TermDOM's disposal, ends the load then, as Fetch ends a fetch +// whatever its Service Worker is doing. +function whileNotAborted( + promise: Promise, + signal: AbortSignal, +): Promise { + if (signal.aborted) { + return Promise.reject(signal.reason); + } + return new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, {once: true}); + promise.then(resolve, reject).finally(() => { + signal.removeEventListener("abort", onAbort); + }); + }); +} + // The policies a page stated for itself with in its head. const documentPolicies = new WeakMap(); diff --git a/tests/requests.test.ts b/tests/requests.test.ts index 4456094a..e2da53fd 100644 --- a/tests/requests.test.ts +++ b/tests/requests.test.ts @@ -5,8 +5,12 @@ * listener answers. A redirect is checked against the policy again and * followed on the network. */ +import {mkdtempSync, rmSync, writeFileSync} from "node:fs"; import {createServer} from "node:http"; import type {AddressInfo} from "node:net"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import {pathToFileURL} from "node:url"; import {expect, test} from "@b9g/libuild/test"; @@ -42,8 +46,8 @@ interface Server { close(): Promise; } -// A server that answers /redirect?to=URL with a 302 to it, and any other -// path with the PNG. +// A server that answers /redirect?to=URL with a 302 to it, /hop/N with a +// 302 to /hop/N-1 until /hop/0, and any other path with the PNG. async function serve(): Promise { const heard: string[] = []; const server = createServer((request, response) => { @@ -54,6 +58,12 @@ async function serve(): Promise { response.end(); return; } + const hop = /^\/hop\/(\d+)$/.exec(url.pathname); + if (hop !== null && hop[1] !== "0") { + response.writeHead(302, {location: `/hop/${Number(hop[1]) - 1}`}); + response.end(); + return; + } response.writeHead(200, {"content-type": "image/png"}); response.end(PNG); }); @@ -182,6 +192,101 @@ test("a redirect is checked against the policy again, and reported as the URL as await server.close(); }); +test("a redirect goes only to another HTTP(S) URL", async () => { + const server = await serve(); + const folder = mkdtempSync(join(tmpdir(), "termdom-redirect-")); + try { + writeFileSync(join(folder, "secret.png"), PNG); + const file = pathToFileURL(join(folder, "secret.png")).href; + const data = `data:image/png;base64,${Buffer.from(PNG).toString("base64")}`; + const dom = create({ + url: pathToFileURL(`${folder}/`).href, + csp: `img-src file: data: cid: ${server.origin}`, + }); + // The file loads when the page asks for it, through the listener, + // which a redirect never reaches. + const answered: string[] = []; + dom.addEventListener("fetch", (event) => { + if (event.request.url.startsWith("file:")) { + answered.push(event.request.url); + event.respondWith(new Response(PNG)); + } + }); + const to = (target: string) => + `${server.origin}/redirect?to=${encodeURIComponent(target)}`; + dom.document.body.innerHTML = + `` + + ``; + const images = [...dom.document.querySelectorAll("img")]; + expect(await Promise.all(images.map(settled))) + .toEqual(["broken", "broken", "broken", "loaded"]); + expect(answered).toEqual([file]); + dom.dispose(); + } finally { + await server.close(); + rmSync(folder, {recursive: true}); + } +}); + +test("twenty redirects are followed, and a twenty-first fails", async () => { + const server = await serve(); + const dom = create({csp: `img-src ${server.origin}`}); + dom.document.body.innerHTML = + ``; + const a = dom.document.getElementById("a") as HTMLImageElement; + const b = dom.document.getElementById("b") as HTMLImageElement; + expect([await settled(a), await settled(b)]).toEqual(["loaded", "broken"]); + dom.dispose(); + await server.close(); +}); + +test("a listener that passes the load on is checked where the load ended up", async () => { + const server = await serve(); + const elsewhere = `http://localhost:${server.port}/secret.png`; + const dom = create({csp: `img-src ${server.origin}`}); + dom.addEventListener("fetch", (event) => { + event.respondWith(fetch(event.request)); + }); + const violations: SecurityPolicyViolationEvent[] = []; + dom.document.addEventListener("securitypolicyviolation", (event) => { + violations.push(event as SecurityPolicyViolationEvent); + }); + const away = `${server.origin}/redirect?to=${encodeURIComponent(elsewhere)}`; + dom.document.body.innerHTML = + ``; + const a = dom.document.getElementById("a") as HTMLImageElement; + const b = dom.document.getElementById("b") as HTMLImageElement; + expect([await settled(a), await settled(b)]).toEqual(["broken", "loaded"]); + expect(violations.map((violation) => violation.blockedURI)).toEqual([away]); + dom.dispose(); + await server.close(); +}); + +test("an answer still pending ends when the source changes or the TermDOM is disposed", async () => { + const dom = create({csp: "img-src https:"}); + const handled: Array> = []; + dom.addEventListener("fetch", (event) => { + handled.push(event.handled); + event.respondWith(new Promise(() => {})); + }); + dom.document.body.innerHTML = ""; + const image = dom.document.querySelector("img")!; + await until(() => handled.length === 1); + image.src = "https://example.com/b.png"; + await until(() => handled.length === 2); + const first = await handled[0].then(() => "resolved", (error: DOMException) => + error.name); + expect(first).toBe("NetworkError"); + void dom.dispose(); + expect(await settled(image)).toBe("broken"); + const second = await handled[1].then( + () => "resolved", + (error: DOMException) => + error.name, + ); + expect(second).toBe("NetworkError"); +}); + test("a listener's redirect is followed on the network, and checked", async () => { const server = await serve(); const dom = create({csp: `img-src cid: ${server.origin}`}); From 050671f83b0112a90e2efbde8d9f1381570386a3 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Tue, 6 Oct 2026 11:39:28 -0400 Subject: [PATCH 27/28] Decode images on the runtime's own Worker, or on the page's thread Without a global Worker, the decoder reached for Node's worker_threads through process, and the worker script found Node's parentPort the same way, though the engine otherwise leaves the process to the transport. It now uses only the runtime's web Worker: Bun's, Deno's, a browser's, and Node's from 26.9 with --experimental-web-worker. Without one, images decode on the page's thread a slice at a time, as a CommonJS build already did. The worker script is plain web worker code. A running worker keeps every runtime alive, and the standard Worker has no unref(), so the worker now also ends when the last TermDOM is disposed, along with any decode it was running. It still ends a second after its last decode. The tests that need a Worker skip without one, which on Node means without the flag. The images guide says how to turn it on. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- README.md | 4 +- docs/guides/07-images-and-canvas.md | 16 ++- src/decode-worker.ts | 56 ++------ src/index.ts | 15 +- src/internal/images.ts | 104 +++++++------- tests/images.test.ts | 208 ++++++++++++++++------------ tests/img.test.ts | 4 +- 7 files changed, 213 insertions(+), 194 deletions(-) diff --git a/README.md b/README.md index 4a9ca97a..76c1caa8 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,8 @@ setInterval(() => { by grapheme, and text fields bind the readline chords (Ctrl+A/E/K/U/W). - **Fullscreen** `Element.requestFullscreen()` renders to the alternate screen; exiting restores the shell and its scrollback. -- **Images and canvas** `` decodes PNG, JPEG, GIF and BMP on a - worker, and `` has a 2D context and a `"charactergrid"` +- **Images and canvas** `` decodes PNG, JPEG, GIF and BMP on the + runtime's web `Worker` where there is one, and `` has a 2D context and a `"charactergrid"` context; pixels are drawn two to a cell with half blocks. Images load only what the program's Content Security Policy allows, such as `new TermDOM({csp: "img-src https: data:"})`, and a diff --git a/docs/guides/07-images-and-canvas.md b/docs/guides/07-images-and-canvas.md index 932416a7..5efed542 100644 --- a/docs/guides/07-images-and-canvas.md +++ b/docs/guides/07-images-and-canvas.md @@ -180,11 +180,17 @@ asks for before it allocates, and an image past a limit fails to load: - a PNG's data inflated only as far as its image needs - a JPEG of at most four components and 500 scans -Images decode on a worker thread, one at a time, so a large one does -not stall the page. A decode that takes more than 10 seconds is -stopped and that image fails; the images behind it still decode. Where -no worker can start, as in a CommonJS build, images decode on the -page's thread a slice at a time, under the same limit. +Images decode one at a time on the runtime's web `Worker`, so a large +one does not stall the page. Bun and Deno have one. Node has one from +26.9 behind a flag, so run `node --experimental-web-worker`, or set +`NODE_OPTIONS=--experimental-web-worker`, to decode off the page's +thread there. Without a `Worker`, and in a CommonJS build, images decode +on the page's thread a slice at a time, so input and frames go on +between slices. A decode that takes more than 10 seconds is stopped +and that image fails; the images behind it still decode. + +The worker ends a second after its last decode, and when the last +TermDOM is disposed, so it never keeps a finished program running. A new `src` cancels the request the old one started, and drops its decode if it is still waiting for its turn. An image whose diff --git a/src/decode-worker.ts b/src/decode-worker.ts index 2d38e11d..b3803f10 100644 --- a/src/decode-worker.ts +++ b/src/decode-worker.ts @@ -1,49 +1,29 @@ /** - * The thread images decode on. It takes `{id, bytes}` and answers - * `{id, width, height, naturalWidth, naturalHeight, data}` with the - * pixels' buffer transferred, kept at most MAX_KEPT_PIXELS, or - * `{id, error}`. It answers `{ready: true}` first, so the page can tell - * a worker that never started from one that failed on an image. - * - * A web worker answers through its global scope. Node's worker_threads - * answer through parentPort. + * The thread images decode on, started as a web Worker. It takes + * `{id, bytes}` and answers `{id, width, height, naturalWidth, + * naturalHeight, data}` with the pixels' buffer transferred, kept at most + * MAX_KEPT_PIXELS, or `{id, error}`. It answers `{ready: true}` first, so + * the page can tell a worker that never started from one that failed on + * an image. */ import {decodeImageForPage} from "./internal/images.ts"; -interface Port { +interface WorkerScope { postMessage(message: unknown, transfer?: Transferable[]): void; - on?(type: "message", listener: (data: unknown) => void): void; - addEventListener?( + addEventListener( type: "message", listener: (event: {data: unknown}) => void, ): void; } -interface WorkerThreads { - parentPort?: Port | null; -} - -interface NodeProcess { - getBuiltinModule?(id: string): WorkerThreads | undefined; -} - -function getNodeParent(): Port | null { - const process = (globalThis as {process?: NodeProcess}).process; - if (process?.getBuiltinModule === undefined) { - return null; - } - return process.getBuiltinModule("node:worker_threads")?.parentPort ?? null; -} - -const nodeParent = getNodeParent(); -const port: Port = nodeParent ?? (globalThis as unknown as Port); +const scope = globalThis as unknown as WorkerScope; async function answer(data: unknown): Promise { const {id, bytes} = data as {id: number; bytes: ArrayBuffer}; try { const bitmap = await decodeImageForPage(new Uint8Array(bytes)); const pixels = bitmap.data.buffer as ArrayBuffer; - port.postMessage( + scope.postMessage( { id, width: bitmap.width, @@ -55,20 +35,14 @@ async function answer(data: unknown): Promise { [pixels], ); } catch (error) { - port.postMessage({ + scope.postMessage({ id, error: error instanceof Error ? error.message : String(error), }); } } -if (nodeParent !== null) { - nodeParent.on!("message", (data) => { - answer(data).catch(() => {}); - }); -} else { - port.addEventListener!("message", (event) => { - answer(event.data).catch(() => {}); - }); -} -port.postMessage({ready: true}); +scope.addEventListener("message", (event) => { + answer(event.data).catch(() => {}); +}); +scope.postMessage({ready: true}); diff --git a/src/index.ts b/src/index.ts index a048994b..b227241d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,7 +44,11 @@ import { transportFromProcess, } from "./internal/exchange.ts"; import {Framebuffer} from "./internal/framebuffer.ts"; -import {setDecodeWorkerURL} from "./internal/images.ts"; +import { + releaseDecoder, + retainDecoder, + setDecodeWorkerURL, +} from "./internal/images.ts"; import {Input} from "./internal/input.ts"; import {Layout} from "./internal/layout.ts"; import {Painter} from "./internal/painter.ts"; @@ -72,9 +76,10 @@ export type { TerminalTransport, } from "./internal/exchange.ts"; -// Images decode on a worker running src/decode-worker.ts, which builds to -// dist/decode-worker.js beside this file. A CommonJS build has no -// import.meta.url, and decodes on this thread instead. +// Images decode on the runtime's web Worker, running src/decode-worker.ts, +// which builds to dist/decode-worker.js beside this file. A runtime with +// no Worker, as Node is without --experimental-web-worker, and a CommonJS +// build, which has no import.meta.url, decode on this thread instead. setDecodeWorkerURL( typeof import.meta.url === "string" ? new URL( @@ -275,6 +280,7 @@ export class TermDOM extends EventTarget { this[kAttachBegun] = Promise.resolve(); this[kStaticSibling] = null; this[kLifetimes] = new Set(); + retainDecoder(); this[kLoads] = new AbortController(); this[kTransport] = options.transport ?? transportFromProcess(); @@ -677,6 +683,7 @@ export class TermDOM extends EventTarget { const wasAttached = isAttached(this); this[kLifecycle] = "disposed"; this[kLoads].abort(); + releaseDecoder(); setDocumentVisible(this.document, false); // Frames painted in place, so nothing reached the scrollback. Write the diff --git a/src/internal/images.ts b/src/internal/images.ts index d560b0a3..59e6ba88 100644 --- a/src/internal/images.ts +++ b/src/internal/images.ts @@ -2061,14 +2061,6 @@ export function isReplacedElement(owner: object): boolean { // --------------------------------------------------------------------------- // Reading what a src names. -interface ProcessWithBuiltins { - getBuiltinModule?: (name: string) => unknown; -} - -function getProcess(): ProcessWithBuiltins | undefined { - return (globalThis as {process?: ProcessWithBuiltins}).process; -} - /** * The URL a src names, against the document's URL, or null. A relative * src in a document with no URL of its own, at about:blank, names @@ -2154,7 +2146,8 @@ export async function readImageResponse( * yield, and the image fails. */ let decodeTimeout = 10_000; -// A worker with nothing to do is ended, so it keeps no process alive. +// A worker with nothing to do is ended, so it keeps the runtime alive for +// a second at most. const WORKER_IDLE_MS = 1_000; let workerURL: URL | null = null; @@ -2258,7 +2251,6 @@ function startNextDecode(): void { const idle = current; if (idle !== null && idle.idle === null) { idle.idle = setTimeout(() => endWorker(idle), WORKER_IDLE_MS); - (idle.idle as {unref?(): void}).unref?.(); } return; } @@ -2419,62 +2411,62 @@ function endWorker(state: WorkerState): void { state.worker.terminate(); } -// A web worker where the runtime has one, Node's worker_threads where it -// has not. Either way, one that would keep the process alive is unref'd. +interface WebWorker { + postMessage(message: unknown, transfer: Transferable[]): void; + addEventListener(type: string, listener: (event: any) => void): void; + terminate(): void; +} + +// The runtime's own web Worker, where it has one: Bun, Deno, a browser, +// and Node from 26.9 with --experimental-web-worker. Without one, images +// decode on this thread. function startWorker( url: URL, onMessage: (data: unknown) => void, onError: (error: unknown) => void, ): DecodeWorker | null { + const Worker = (globalThis as {Worker?: new ( + url: URL, + options: {type: "module"}, + ) => WebWorker;}).Worker; + if (Worker === undefined) { + return null; + } try { - const Web = (globalThis as {Worker?: new ( - url: URL, - options: {type: "module"} - ) => { - postMessage(message: unknown, transfer: Transferable[]): void; - addEventListener(type: string, listener: (event: any) => void): void; - terminate(): void; - unref?(): void; - };}).Worker; - if (Web !== undefined) { - const worker = new Web(url, {type: "module"}); - worker.addEventListener("message", (event) => onMessage(event.data)); - worker.addEventListener("error", (event) => { - event.preventDefault?.(); - onError(event.error ?? event); - }); - worker.unref?.(); - return { - post: (message, transfer) => worker.postMessage(message, transfer), - terminate: () => worker.terminate(), - }; - } - const process = getProcess(); - if (process?.getBuiltinModule === undefined) { - return null; - } - const threads = process.getBuiltinModule("node:worker_threads") as { - Worker: new (url: URL) => { - postMessage(message: unknown, transfer: Transferable[]): void; - on(type: string, listener: (value: any) => void): void; - terminate(): Promise; - unref(): void; - }; - } | undefined; - if (threads === undefined) { - return null; - } - const worker = new threads.Worker(url); - worker.on("message", onMessage); - worker.on("error", onError); - worker.unref(); + const worker = new Worker(url, {type: "module"}); + worker.addEventListener("message", (event) => onMessage(event.data)); + worker.addEventListener("error", (event) => { + event.preventDefault?.(); + onError(event.error ?? event); + }); return { post: (message, transfer) => worker.postMessage(message, transfer), - terminate: () => { - worker.terminate().catch(() => {}); - }, + terminate: () => worker.terminate(), }; } catch (_error) { return null; } } + +// The TermDOMs alive that may decode. A running worker keeps the runtime +// alive, so when the last is disposed the worker ends with it, and with +// it any decode still running. +let decoderUsers = 0; + +export function retainDecoder(): void { + decoderUsers++; +} + +export function releaseDecoder(): void { + decoderUsers = Math.max(0, decoderUsers - 1); + if (decoderUsers > 0 || current === null) { + return; + } + if (running !== null && running.timer !== null) { + clearTimeout(running.timer); + const {job} = running; + running = null; + job.reject(new DOMException("The decoder was closed", "AbortError")); + } + endWorker(current); +} diff --git a/tests/images.test.ts b/tests/images.test.ts index 221b4088..6429ccbd 100644 --- a/tests/images.test.ts +++ b/tests/images.test.ts @@ -19,7 +19,9 @@ import { MAX_IMAGE_BYTES, MAX_KEPT_PIXELS, readImageResponse, + releaseDecoder, resolveImageURL, + retainDecoder, sampleBitmap, setDecodeTimeout, setDecodeWorkerURL, @@ -455,17 +457,24 @@ test("an image response past the byte limit is not read whole", async () => { // request as `reply` says, or never. function fakeWorker(reply: string): URL { const source = - "const t = globalThis.process?.getBuiltinModule?.(\"node:worker_threads\");" + - "const port = t?.parentPort ?? globalThis;" + + "const port = globalThis;" + "const answer = (data) => {" + reply + "};" + - "if (t?.parentPort) t.parentPort.on(\"message\", answer);" + - "else globalThis.addEventListener(\"message\", (e) => answer(e.data));" + - "port.postMessage({ready: true});"; + "addEventListener(\"message\", (e) => answer(e.data));" + + "postMessage({ready: true});"; return new URL(`data:text/javascript,${encodeURIComponent(source)}`); } +// These need the runtime's own Worker, which Node has only with +// --experimental-web-worker. Without one, images decode on this thread, +// which the tests after them cover. +const withWorker = + typeof (globalThis as {Worker?: unknown}).Worker === + "function" + ? test + : test.skip; + // A one-byte image stands for a slow or broken one in these workers. const STAND_IN = new Uint8Array([0]); @@ -482,88 +491,117 @@ function outcomes(decodes: Array>): Promise { const ANSWER = "port.postMessage({id: data.id, width: 1, height: 1, data: new ArrayBuffer(4)})"; -test("an image past its time fails alone, and the images behind it decode", async () => { - try { - setDecodeTimeout(1000); - setDecodeWorkerURL( - fakeWorker(`if (data.bytes.byteLength !== 1) ${ANSWER};`), - ); - expect( - await outcomes([ - decodeImageOffThread(STAND_IN), +withWorker( + "an image past its time fails alone, and the images behind it decode", + async () => { + try { + setDecodeTimeout(1000); + setDecodeWorkerURL( + fakeWorker(`if (data.bytes.byteLength !== 1) ${ANSWER};`), + ); + expect( + await outcomes([ + decodeImageOffThread(STAND_IN), + decodeImageOffThread(bytesOf("png-rgb.png")), + decodeImageOffThread(bytesOf("png-rgb.png")), + ]), + ).toEqual([ + "The image took more than 1000 ms to decode", + "1 wide", + "1 wide", + ]); + } finally { + setDecodeTimeout(10_000); + setDecodeWorkerURL(null); + } + }, +); + +withWorker( + "a decode aborted while it waits is dropped, and the line moves on", + async () => { + try { + setDecodeTimeout(1000); + setDecodeWorkerURL( + fakeWorker(`if (data.bytes.byteLength !== 1) ${ANSWER};`), + ); + const abort = new AbortController(); + const started = Date.now(); + let droppedAfter = -1; + // The stand-in, ahead of it, holds the worker until its time is up. + const stalled = decodeImageOffThread(STAND_IN); + const dropped = decodeImageOffThread(bytesOf("png-rgb.png"), abort.signal) + .finally(() => { + droppedAfter = Date.now() - started; + }); + const decodes = [ + stalled, + dropped, decodeImageOffThread(bytesOf("png-rgb.png")), + ]; + abort.abort(new Error("The source changed")); + expect(await outcomes(decodes)).toEqual([ + "The image took more than 1000 ms to decode", + "The source changed", + "1 wide", + ]); + expect(droppedAfter).toBeLessThan(500); + await expect( + decodeImageOffThread(bytesOf("png-rgb.png"), abort.signal), + ).rejects.toThrow("The source changed"); + } finally { + setDecodeTimeout(10_000); + setDecodeWorkerURL(null); + } + }, +); + +withWorker( + "a worker that fails on an image fails that image alone", + async () => { + try { + setDecodeWorkerURL( + fakeWorker( + `if (data.bytes.byteLength === 1) throw new Error("broke"); ${ANSWER};`, + ), + ); + const [before, broken, after] = await outcomes([ decodeImageOffThread(bytesOf("png-rgb.png")), - ]), - ).toEqual([ - "The image took more than 1000 ms to decode", - "1 wide", - "1 wide", - ]); - } finally { - setDecodeTimeout(10_000); - setDecodeWorkerURL(null); - } -}); - -test("a decode aborted while it waits is dropped, and the line moves on", async () => { - try { - setDecodeTimeout(1000); - setDecodeWorkerURL( - fakeWorker(`if (data.bytes.byteLength !== 1) ${ANSWER};`), - ); - const abort = new AbortController(); - const started = Date.now(); - let droppedAfter = -1; - // The stand-in, ahead of it, holds the worker until its time is up. - const stalled = decodeImageOffThread(STAND_IN); - const dropped = decodeImageOffThread(bytesOf("png-rgb.png"), abort.signal) - .finally(() => { - droppedAfter = Date.now() - started; - }); - const decodes = [ - stalled, - dropped, - decodeImageOffThread(bytesOf("png-rgb.png")), - ]; - abort.abort(new Error("The source changed")); - expect(await outcomes(decodes)).toEqual([ - "The image took more than 1000 ms to decode", - "The source changed", - "1 wide", - ]); - expect(droppedAfter).toBeLessThan(500); - await expect( - decodeImageOffThread(bytesOf("png-rgb.png"), abort.signal), - ).rejects.toThrow("The source changed"); - } finally { - setDecodeTimeout(10_000); - setDecodeWorkerURL(null); - } -}); - -test("a worker that fails on an image fails that image alone", async () => { - try { - setDecodeWorkerURL( - fakeWorker( - `if (data.bytes.byteLength === 1) throw new Error("broke"); ${ANSWER};`, - ), - ); - const [before, broken, after] = await outcomes([ - decodeImageOffThread(bytesOf("png-rgb.png")), - decodeImageOffThread(STAND_IN), - decodeImageOffThread(bytesOf("png-rgb.png")), - ]); - expect([before, after]).toEqual(["1 wide", "1 wide"]); - expect(broken).toContain("broke"); - setDecodeWorkerURL( - fakeWorker("port.postMessage({id: data.id, error: \"from the worker\"})"), - ); - await expect(decodeImageOffThread(bytesOf("png-rgb.png"))) - .rejects.toThrow("from the worker"); - } finally { - setDecodeWorkerURL(null); - } -}); + decodeImageOffThread(STAND_IN), + decodeImageOffThread(bytesOf("png-rgb.png")), + ]); + expect([before, after]).toEqual(["1 wide", "1 wide"]); + expect(broken).toContain("broke"); + setDecodeWorkerURL( + fakeWorker( + "port.postMessage({id: data.id, error: \"from the worker\"})", + ), + ); + await expect(decodeImageOffThread(bytesOf("png-rgb.png"))) + .rejects.toThrow("from the worker"); + } finally { + setDecodeWorkerURL(null); + } + }, +); + +withWorker( + "the last TermDOM disposed ends the worker, and the decode it ran", + async () => { + try { + setDecodeWorkerURL(fakeWorker("")); + retainDecoder(); + const decode = decodeImageOffThread(bytesOf("png-rgb.png")); + await new Promise((resolve) => setTimeout(resolve, 50)); + releaseDecoder(); + const outcome = await decode.then(() => "decoded", (error: Error) => + error.name); + expect(outcome).toBe("AbortError"); + } finally { + setDecodeWorkerURL(null); + } + }, +); test("where no worker starts, every image decodes on this thread", async () => { try { diff --git a/tests/img.test.ts b/tests/img.test.ts index 35da7407..fc5530a1 100644 --- a/tests/img.test.ts +++ b/tests/img.test.ts @@ -266,7 +266,9 @@ test("an image sits on a line with the text around it", async () => { await mount(`

before after

`); await loaded(document.querySelector("img")!); await nextFrame(dom); - expect(rowText(terminal, 0)).toBe("before ▄▄ after"); + // A frame drawn while it loaded showed its wider loading box, whose + // last cell was erased. + expect(rowText(terminal, 0).trimEnd()).toBe("before ▄▄ after"); // The image's computed display stays inline, as in a browser. expect( dom.window.getComputedStyle(document.querySelector("img")!).display, From e7b50efb5741e82cf023fc8700cb7e63c2ddc4af Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Tue, 6 Oct 2026 12:51:58 -0400 Subject: [PATCH 28/28] Say how a Node program can give TermDOM a Worker without the flag TermDOM decodes images on whatever global Worker there is. The images guide now shows the two lines that set one from @b9g/node-webworker on a Node without --experimental-web-worker, leaving Node's own in place where it has one. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D --- docs/guides/07-images-and-canvas.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/guides/07-images-and-canvas.md b/docs/guides/07-images-and-canvas.md index 5efed542..4763b658 100644 --- a/docs/guides/07-images-and-canvas.md +++ b/docs/guides/07-images-and-canvas.md @@ -192,6 +192,18 @@ and that image fails; the images behind it still decode. The worker ends a second after its last decode, and when the last TermDOM is disposed, so it never keeps a finished program running. +On a Node without the flag, a program can supply a `Worker` itself. +TermDOM uses whatever global `Worker` there is, so the +[`@b9g/node-webworker`](https://www.npmjs.com/package/@b9g/node-webworker) +shim gives it one on any Node 18 or later: + +```ts +import {Worker} from "@b9g/node-webworker"; +globalThis.Worker ??= Worker; +``` + +Where Node has its own `Worker`, the `??=` leaves it in place. + A new `src` cancels the request the old one started, and drops its decode if it is still waiting for its turn. An image whose box would sample more than 2^26 pixels draws nothing, as does a 2D