Skip to content

Images after #125: opt-in loading, worker decoding, limits, alt boxes, CellContext grids - #136

Merged
brainkim merged 28 commits into
mainfrom
images-canvas
Oct 6, 2026
Merged

brainkim merged 28 commits into
mainfrom
images-canvas

Conversation

@brainkim

@brainkim brainkim commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

Follow-on work after #125 (images and canvas) merged.

Loading is decided by a Content Security Policy

TermDOM used to fetch every image source itself. It loaded http(s) through the runtime's fetch, read file: from disk and parsed data: itself. A program showing untrusted HTML couldn't stop a tracking image or a read of a local file, and an email's cid: images had no way to load.

Which loads a document may make is now its Content Security Policy, the standard built for exactly this question:

new TermDOM({csp: "img-src https: data:"}); // the web and data: URLs
new TermDOM({csp: "img-src cid: data:"});   // a mail client
new TermDOM();                                                // "default-src 'none'": nothing
  • Directives: an <img> loads what img-src allows, or default-src without it. Later resource types take their own directives with no new API.
  • Matching follows CSP Level 3: 'none', 'self', * (the network schemes and the document's own, not data: or cid:), schemes, and hosts with *. wildcards, ports and paths. A host without a scheme takes the document's, and for a document not served over the network (about:blank, file:), the network schemes.
  • Pages can only tighten it: a <meta http-equiv="Content-Security-Policy"> inserted into the document's head is enforced too, and a load must pass every policy. As HTML says, it holds from its insertion: removing the tag or changing its content later changes nothing.
  • Redirects are checked again. Each redirect is checked against the policy, matched without its path as CSP3 says, goes only to another http: or https: URL (never a file or data:), and at most 20 are followed.
  • Blocked loads are reported the standard way: securitypolicyviolation fires at the element, a new SecurityPolicyViolationEvent (complete per CSP3) that bubbles to the document with the URL, directive and policy. The URL is the one the document asked for, never a redirect's target, without its fragment, and only its scheme when it isn't HTTP(S). When the program set no policy, the first block is also reported once where uncaught errors go, saying what to set.
  • file: only from file pages. As in a browser, a document whose URL isn't file: loads no file: URL, whatever the policy says.
  • csp: "" sets no policy and allows every load, as an empty header does.

What the policy allows goes to the TermDOM as a Service Worker's FetchEvent, for answers decided request by request, and to the runtime's fetch when no listener answers:

term.addEventListener("fetch", (event) => {
  event.request;              // a Request; destination is "image", signal aborts on a new src
  event.respondWith(response); // stops the other listeners; Response.error() fails the load
  event.preventDefault();      // without an answer, also fails the load
  event.waitUntil(promise);    // dispose() waits for it
  event.handled;               // settles once answered; rejects with a NetworkError on failure
});
  • The order is a browser's: policy, then listeners, then network. A load a listener answers must be allowed too, so serving cid: takes img-src cid: and a listener. A redirect, from the network or a listener's Response.redirect(), is followed on the network without the listeners, as Fetch follows one without its Service Worker.
  • The spec's fields: request, handled, preloadResponse, clientId, resultingClientId, replacesClientId, respondWith(), and waitUntil() from ExtendableEvent. Both classes are exported. A FetchEvent a program makes and dispatches itself can't be answered or extended, as in a Service Worker.
  • Per-image decisions belong in the markup, such as no remote images in quoted mail: the program cleans it before inserting it, as a mail client does. WebKit's beforeload event tried this and was removed.
  • A listener's response is checked where it came from. A listener that answers with fetch(event.request) lets the runtime follow redirects, so its response is checked against the policy at its final URL, as a browser checks a Service Worker's response.
  • dispose() aborts loads still in flight and waits for waitUntil() promises. A load waiting on a listener's answer ends when its src changes or the TermDOM is disposed, whether or not the answer comes, and handled rejects with a NetworkError.
  • window.fetch is the runtime's and resolves a relative URL against the document. No policy governs it and it dispatches no event, since in a terminal the program, not untrusted markup, calls it.
  • Loads wait a microtask, as HTML's "update the image data" does, so a listener added right after the constructor's markup still sees its images.
  • Fixed along the way: images in the constructor's html never loaded before. The parser built them before the document had a window, and nothing restarted them. The window now does.
  • Examples and docs: examples/images.ts sets img-src file: data: https: and answers file: URLs in a fetch listener, since Node's fetch doesn't read files. The guide's "Loading" section covers the policy and the event.

Decoding on the runtime's Worker, or on the page's thread

A 12-megapixel JPEG blocked the page for about 840 ms. Images now decode in src/decode-worker.ts, plain web worker code built next to index.js as its own entry (listed in exports, since libuild only builds entries exports names). It runs on the runtime's own global Worker: Bun's, Deno's, a browser's, and Node's from 26.9 with --experimental-web-worker (nodejs/node#64894). There, the longest block for that JPEG is 10 to 22 ms. TermDOM does not touch process or worker_threads for this, and does not offer window.Worker to pages.

  • Without a Worker (Node without the flag, the CommonJS build, libuild's test bundles), every image decodes on the page's thread, in the same line. The PNG and JPEG decoders yield every 16 ms, so input and frames go on between slices, and a decode past the same 10 seconds stops at its next yield.
  • Node without the flag can get a worker with two lines and @b9g/node-webworker 0.2.2, which the images guide shows: globalThis.Worker ??= Worker. TermDOM does not depend on it.
  • One at a time: images decode in the order they ask, one on the worker at a time. A page holds one decode's memory at once, and an image's timer counts its own decode, not time waiting in line or the worker starting.
  • Stale decodes are dropped: a decode still waiting when its image's src changes, or its TermDOM is disposed, leaves the line with its bytes.
  • Timeout: a decode past 10 seconds ends the worker and fails that image alone; a new worker takes the next one.
  • Ending the worker: a running worker keeps Node, Bun and Deno alive, and the standard Worker has no unref(). So the worker ends a second after its last decode, and when the last TermDOM is disposed, along with any decode it was running.
  • Crashes: a worker that started and then failed on an image fails that image alone, and a new worker takes the next. One that never starts sends every image to the page's thread.

At most a megapixel kept per image

An image used to stay decoded at full size, up to 2^26 pixels (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 pixels, 4 MB) is now averaged down to it as it decodes, on the worker or the main thread, and keeps its natural size beside the pixels: that photo keeps 1182×886 pixels, 4.2 MB.

Everything a page measures still uses the natural size: naturalWidth/naturalHeight, layout and aspect ratio, drawImage's source rectangle, createImageBitmap's crop and size (each side scaled by its own ratio, transparent past the image's edge), and the tile a pattern repeats at. sampleBitmap takes its rectangle in natural pixels and maps it to the pixels kept.

Limits against image bombs

Checked before anything is allocated:

  • every decoder: at most 32,768 pixels per side and 2^26 in all
  • PNG: data is inflated only as far as the image needs
  • JPEG: one to four components, sampled one to four times, and at most 500 scans
  • GIF: a code size of 2 to 8 bits
  • responses: at most 64 MB, by the declared length and again while reading
  • a new src aborts the request the old one started
  • ImageData past 2^26 pixels throws a RangeError
  • a charactergrid's drawImage samples only the cells that land on the grid, however large the destination
  • an image box that would sample past 2^26 pixels draws nothing
  • a character grid stops at 2^22 cells

An image that isn't showing is a box with its alt text

An image that's loading, broken or blocked showed its alt text inline, or nothing at all while loading. It's now a bordered box with the alt text inside, cut with "…" to 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 holds the text alone. alt="" shows nothing, and neither does an <img> with no src and no alt. The border is a placeholder, not a CSS border: it's drawn inside the content box in the text color.

charactergrid through CellContext, with lines and boxes

The charactergrid context and image half blocks each kept cells in their own type and copied them into the frame one cell at a time. Both are CellGrids now, and CellContext.drawGrid copies either into the frame. The context gains canvas-style lines:

g.strokeStyle = "#5fafff"; // a CSS color, currentcolor by default
g.lineStyle = "double";    // a CSS border-style
g.lineJoin = "round";      // rounds strokeRect()'s corners
g.strokeRect(0, 0, 20, 5);
g.strokeLine(0, 2, 19, 2); // joins the box's sides as tees

Lines join into corners, tees and crosses where they meet. They're drawn as glyphs before reaching the frame, so they don't join the page's borders around the canvas.

Two pixels as a lower half block

A two-color cell is now ▄ (bottom pixel) over a background (top pixel), not ▀ over a background. Most terminals draw block characters themselves and show either exactly (kitty, Ghostty, WezTerm, Alacritty, foot, VTE, Windows Terminal, xterm.js with WebGL). Terminal.app draws them from the font, and of 22 coding fonts measured, 12 put the split between the halves off center, mostly low (Menlo and SF Mono at 57–59% of the cell, and their blocks also start 14–16% below the top). With ▀, that left a stripe of the wrong color at each cell's top and banded every gradient. With ▄ the background fills whatever the glyph misses, so only the seam is off, and ▄ matches or beats ▀ in 18 of the 19 fonts that have half blocks. timg, viu and rich-pixels use ▄ for the same reason. A cell where only the top pixel shows keeps ▀, since the color under it is the page's.

Smaller changes

  • Typed listeners: TermDOMEventMap and keyed addEventListener/removeEventListener overloads, so a "fetch" listener gets a FetchEvent without a cast.

  • A leak test for images and canvases: 80 rounds of four 256×256 images and two drawn canvases, shown and removed, with half the images removed before they load. It measures the heap plus external memory, since decoded pixels are array buffers the heap doesn't count. With every decoded image deliberately kept, it reads 86 MB and fails; it passes on Node, Bun and Deno.

  • One cell size for images: the screen owns the one 8×16 fallback, and getImageCellSize gives the cell an image's pixels divide by.

  • Guide: now says how image sizes work with and without cellSize.

Not done

  • No memory budget across a page's images. Each image is capped at a megapixel, but a page with very many images still holds them all.
  • renderANSI(html) and print(html) never show images: they render before an image decodes.
  • No quadrant, sextant or octant rendering yet, and no pixel protocols (kitty, sixel).

🤖 Generated with Claude Code

https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D

@brainkim brainkim changed the title Images and canvas, two pixels a cell Images and canvas follow-ups: one cell size, CellContext grids, image limits Oct 4, 2026
@brainkim brainkim changed the title Images and canvas follow-ups: one cell size, CellContext grids, image limits Images after #125: opt-in loading, worker decoding, limits, alt boxes, CellContext grids Oct 4, 2026
@brainkim
brainkim force-pushed the images-canvas branch 2 times, most recently from 7684747 to ee84a2a Compare October 5, 2026 19:04
brainkim and others added 25 commits October 6, 2026 10:53
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
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 <img> with neither a src nor an alt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
Also stop exporting MAX_IMAGE_SIDE, checkImageSize and ResourceLoader,
which only their own files use.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
…le: 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
…ponses

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
… 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
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 <img> 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 <meta http-equiv="Content-Security-Policy"> 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
A page's <meta http-equiv="Content-Security-Policy"> 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
brainkim and others added 2 commits October 6, 2026 11:39
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
@brainkim
brainkim merged commit 75facb7 into main Oct 6, 2026
7 checks passed
@brainkim
brainkim deleted the images-canvas branch October 9, 2026 16:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant