Skip to the main content
mkfifo.com

Icons from one SVG, and a cache

Updated planned discovered changed fixed

All the icons of mkfifo.com come from one SVG in an envelope, through the builtin resvg. Cloudflare Images made the other sizes for one day, and then a cache in front of the Worker took its place.

favicon.ico is a FIFO

Until today, /favicon.ico and /robots.txt were the files that Phoenix makes for a new project: the icon of Phoenix, and a robots.txt with only comments. Now both are FIFOs of the site, as /fonts: the FIFO mkfifo holds them, and their answers come from envelopes in priv/repo/seeds/mkfifo/. GET /mkfifo shows them in a locked editor.

The icons

I followed "How to Favicon" of Evil Martians, and looked at what changed after its version of 2021. The ICO link now has sizes="32x32", not sizes="any", because of a bug of Chrome (2023). A maskable icon came for the web apps that a person installs (2024). Safari 26 shows SVG icons. Google wants a square icon that its crawler can read, and asks for more than 48 pixels.

The icon is an ingress envelope of the editor: brown paper, the flap, and a blue stamp. scripts/icon.svg is the source, and scripts/icons.mjs makes the files: Chromium draws the SVG at 16, 32, 48 and 180 pixels, and the script writes the ICO file itself. An ICO file is a header of 6 bytes, an entry of 16 bytes for each image, and the images, which can be PNG files.

To: favicon.ico
X-HTTP-Method-Override: ^GET$
Content-Type: form

file: ^$
To: -
Content-Type: image/x-icon
Cache-Control: public, max-age=86400

$(cat icons/favicon.ico)

A second envelope takes ?file=NAME.png and gives the icon of 180 pixels for the home screen of iOS. Each page links it with a version, ?file=apple-touch-icon.png&v=..., so a browser keeps it for one year.

There is no SVG icon. My plan had one, with cat icons/icon.svg. But image/svg+xml is XML, and an answer with an XML body gives each value the escapes of HTML: <svg becomes &lt;svg. That rule stops a capture from adding a tag to a page, and a FIFO of the site gets no exception from it. The ICO file has images for 16, 32 and 48 pixels, which is sufficient for a tab. There is also no web manifest: mkfifo.com is not an app to install.

A dynamic robots.txt

A FIFO can give a different answer to a different Request. robots.txt has two envelopes, one for each value of Host:

To: robots.txt
X-HTTP-Method-Override: ^GET$
Host: ^(mkfifo\.com)?$

For mkfifo.com (or a Request with no Host), the answer lets each search engine read the site, and gives the feed of this blog as a sitemap. For each different host, for example a preview of the Worker or localhost, the answer is Disallow: /, so that a copy of the site does not go into a search engine. A test on localhost:4000 got Disallow: /, so the field Host of the server comes into the Request.

Not the editors

The name of a FIFO is the key to change it. robots.txt cannot list the editors, because each name of a FIFO is a path of the root. So the editor of each FIFO now has <meta name="robots" content="noindex">: if a person puts a link to an editor on a public page, a search engine does not keep it. A rule of robots.txt would not be sufficient: a search engine can keep a URL that it cannot read.

apple-touch-icon.png, apple-touch-icon-precomposed.png and sitemap.xml are names of the site now. Some clients ask for them at the root of a site, so a FIFO of a person must not have these names.

One SVG, all the icons

"How to Favicon" of Evil Martians starts from one SVG, and makes the other icons from it. My first FIFO favicon.ico did not: a script drew the SVG in Chromium on my computer, and the FIFO gave two files from priv/icons/. There was no SVG icon. Now the SVG is the text of an envelope, and each icon comes from it when a browser asks for it.

RequestAnswer
/favicon.svgthe SVG: the text of the answer envelope of the FIFO favicon.svg
/cdn-cgi/image/width=192/favicon.svga PNG of 192 pixels, from Cloudflare Images
/favicon.ico$(printf '\0\0\1\0\1\0\40\40\0\0\1\0\40\0\337\2\0\0\26\0\0\0')$(resvg -w 32 - -c < favicon.svg)
/favicon.ico?file=apple-touch-icon.png$(sed 's/viewBox="0 0 32 32"/viewBox="-4.5 -4.5 41 41"/' < favicon.svg | resvg -w 180 --background '#dde2ea' - -c)

< favicon.svg is the answer of a GET of the FIFO favicon.svg. The icon of iOS needs space around the envelope and no transparency: sed makes the view box larger, and --background gives the color of the desk.

Two FIFOs

My first plan had one FIFO: the SVG in one answer envelope, and the other envelopes read it from the same FIFO. That cannot work. A FIFO that reads itself is a loop, and the save refuses a loop. Then I put the SVG into a file of the site, and cat gave it as markup, because an answer of image/svg+xml gives each value the escapes of HTML. That was an exception to a rule of safety, for one file.

Two FIFOs are better. The SVG is the text of the envelope of favicon.svg, and the text of an envelope gets no escapes: only the values do. favicon.ico reads a different FIFO, so there is no loop. A FIFO that holds a FIFO cannot be held, so the FIFO mkfifo of the site holds both, as it holds fonts and robots.txt.

A converter in the Worker

The site runs in a Cloudflare Worker, so the converter must run there too. I looked at three:

  • ImageMagick. A Worker runs no program.
  • image_plug, on Vix and libvips, in the VM. A NIF in the Worker is WebAssembly, with WASI preview 1 and no threads. libvips needs GLib, and GLib needs threads.
  • Cloudflare Images. It gives an SVG back as it is, with no PNG.

resvg is Rust, with no C libraries. Its crate compiles to wasm32-wasip1, so it can be a NIF in WebAssembly, as the NIF of lazy_html. beam.com has a change for rustler 0.38 that makes the enif_* functions imports of WebAssembly. With Rust 1.97 it needed one more line, #[link(wasm_import_module = "env")]: the linker of Rust for WebAssembly does not accept an unknown function now. The module also exports its malloc and free for the VM. The file is 1 MB, and the build puts it into nifs.js with lazy_html.

The builtin resvg has the name and the options of the program: -w, -h, -z, --dpi, --background, and - -c for the input and the output. It gives the bytes of the program resvg 0.48.1. I compared 13 cases, with gradients, a clip path, a mask, dashes, <use> and the unit mm: the bytes were the same. The tests keep the SHA-256 of four of them. The NIF is in the repository as priv/resvg.wasm, and the CI builds it again from native/resvg and compares the bytes: the build gives the same file on each computer.

Cloudflare Images in front

With the transformations of Cloudflare Images on, /cdn-cgi/image/OPTIONS/SOURCE changes the size and the format of an image of the site. Cloudflare gets the source from the Worker. To see that GET, I saved a test FIFO that agreed only with Via: image-resizing, and answered with a PNG of one pixel. A direct GET got 404, and the URL of Cloudflare got the PNG: the field is there, and the VM sees it.

So now an answer of image/svg+xml to such a Request is the PNG of the SVG, at most 512 by 512 pixels, from resvg. Cloudflare makes each size and format from that PNG. To the outside, an image of an SVG is only the path of Cloudflare, and each FIFO with an answer of SVG gets it, not only the icons.

Two things stay with favicon.ico. Cloudflare makes no ICO files. And it has no format=png: it selects JPEG or PNG itself, and for the icon of iOS, which has no transparency, it gave a JPEG. iOS wants a PNG there.

An SVG from anyone

Any FIFO can give an SVG to resvg, and a call of a NIF cannot stop before it ends. So the NIF examines the SVG before it draws, and refuses each thing that can use much memory or much time: the entities of a DTD (they can copy text a billion times), more than 128 levels of elements, more than 20,000 elements or 2 MiB of attributes after the copies of <use>, a mask or a clip path on each element through a style sheet, an image of more than 1,048,576 pixels, and more than 16 MiB of pixmaps. A layer of resvg can be 25 times the image, so the NIF computes the box of each layer. It also refuses text, images, filters, patterns and markers: the NIF has no fonts and no files, and these are the slowest parts.

The steps of the work count in the steps of the Request, as the matches of grep: the pixels of the image and of its PNG, of each layer, of the box of each path, the rows of each edge, and each dash. The NIF gets the steps that the Request has, and refuses a larger work before it draws. I gave each part its weight from measures: a stroke costs about four times a fill, and a gradient ten times a color.

In wrangler dev, the PNG of 512 pixels takes 160 ms with the HTTP request, the largest work that a Request can do takes 0.23 s, and an SVG with a billion copies gets its error in 22 ms. The memory of the NIF stayed under 15 MiB after the largest work, and a test keeps it under 32 MiB.

The ICO file

An ICO file is a header of 22 bytes for one image, then the image, and the image can be a PNG. printf gives the header: the type 1 and one image (\0\0\1\0\1\0), 32 by 32 pixels (\40\40), one plane of 32 bits, the length of the PNG, and its position, 22. printf had only \n, \t, \r and the quotes. Now it has \NNN and \xHH, as GNU printf, in the same pass as the conversions: \045d gives %d, not a number.

The length is fixed in the envelope: 735 bytes, \337\2\0\0. resvg gives the same bytes for the same SVG, so the length changes only with the SVG, and a test tells the new value. The ICO file now has one image, of 32 pixels: the article recommends it, and the SVG is the icon of the browsers that read SVG.

Two bugs of grep

While I worked on this, the property test of the builtins failed one time: it runs each builtin with random words and inputs, and expects no exception. I ran it on grep for many more inputs, and found two bugs. A pattern of more lines with a character that is not UTF-8, as the bytes E1 0A, raised an exception: each character of such a pattern was compiled also as UTF-8. And an error message with a word that is not UTF-8 raised one in String.length/1. Now that pattern matches by bytes, as GNU grep does, and each byte of an error that is not UTF-8 becomes U+FFFD.

A cache in front of the Worker

Yesterday the other sizes of the icon came from Cloudflare Images, at /cdn-cgi/image/OPTIONS/favicon.svg. Today the transformations are off, and a cache of Cloudflare is in front of the Worker. The images of other sizes will come from the VM, and the cache will keep them.

A transformation counts when it fails

Cloudflare counts each new pair of a source and options as one transformation of the month. I wanted a limit for each FIFO, so I looked at what the VM sees. For the first size of a source, Cloudflare sends one GET to the FIFO. For each new size after it, Cloudflare sends nothing: it uses a copy of the source in its memory, also with Cache-Control: no-store. The GET has no options. So the VM cannot count the transformations.

Then I sent 20 URLs with a source that was not there, each with a different width. Each one failed with the error 9408, and the count of the month went from 22 to 42. A failed transformation counts too. So each person could use the transformations of the month with any URL, and a refusal of the VM could not stop it. I turned the transformations off.

A rule of the firewall of Cloudflare with a secret header could let only the VM through. But then the secret is in two places, and the site can run only on Cloudflare.

/cdn-cgi/ is for Cloudflare

With the transformations off, I wanted the VM to answer /cdn-cgi/image/ itself, with the same options. Then the URL stays, and only the work changes. It cannot: Cloudflare gives its own 404 page for each path of /cdn-cgi/, also for /cdn-cgi/mkfifo-test, and the Worker never gets the request. The images will have a path of mkfifo.com.

The cache

Workers Cache is one line in wrangler.jsonc: "cache": { "enabled": true }. Cloudflare looks in its cache before it runs the Worker, and the Cache-Control of the answer controls it. A copy does not run the Worker or the VM: it uses no CPU, and the rate limits do not see it. The header cf-cache-status tells MISS or HIT.

The cf options of fetch() do not help here: they are for a request that a Worker sends to an origin, and this Worker sends to a Durable Object. And wrangler dev does not have the cache: with the line, two GETs of robots.txt both came to the VM. So the test of the cache is on mkfifo.com.

The key has no host

The key of the cache is the path and the query. It has no host and no header field of the Request. Three answers changed for this:

  • robots.txt has a different text for a host that is not mkfifo.com, for example a preview of the Worker. With public, a preview could put its Disallow: / into the copy of mkfifo.com. Now the two answers have private, max-age=86400: only the browser keeps them.
  • The Worker makes some answers itself: 429, 503, 413, and each answer of sendmail.mkfifo.com. They had no Cache-Control. Now they have no-store.
  • The PNG for a Request with image-resizing in Via: a client with this field could put the PNG into the copy of /favicon.svg. The transformations are off, so I removed the rule.

An answer of a FIFO with public goes into the cache too. A GET in that time does not come to the FIFO: the editor shows no Request, and the egress envelopes send nothing. An answer with no Cache-Control gets max-age=0, private, must-revalidate from Phoenix, so the cache does not keep it. An answer that changes with a header field of the Request must be private.