Public: anyone can read this; the crew can change it.
OG cards as PNG, rasterized once per input hash under FORGE_APP_DATA, behind a deploy that stays up #1993
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1803-og-cards-as-png-rasterized-once-per-inpu → main 5 commits fork 6 threads · 6 unresolved
README.md +27
| @@ -994,3 +994,30 @@ links to the deck, whatever the number of siblings; it is never muted away. | ||
| 994 | 994 | Once a sibling ships or all but one close, the item leaves the feed. The |
| 995 | 995 | terminal has no feed; the item is in `GET /api/v1/feed` and the MCP feed tool |
| 996 | 996 | as kind `choose`. |
| 997 | ||
| 998 | ### OG cards as PNG | |
| 999 | ||
| 1000 | `/og/{repo,app,issue,pull,run}/<ref>.png` is the same public card as the `.svg`, | |
| 1001 | rasterized (1200×630) for the link previews that refuse SVG. Same refs, same | |
| 1002 | 404s, same ETag-per-inputs (suffixed `-png`). Each PNG is rendered once and kept | |
| 1003 | under `$FORGE_APP_DATA/og-png/<sha256 of the inputs>.png`; the cache holds at | |
| 1004 | most 512 files (`OG_PNG_CAP` in `src/web/og/png-cache.ts`), least recently used | |
| 1005 | out first. Cold is about 120 ms, warm a file read. If the rasterizer will not | |
| 1006 | load or render, the route answers 503 with `retry-after: 30` and logs; nothing | |
| 1007 | else notices, since the rasterizer is only loaded on the first PNG request. | |
| 1008 | ||
| 1009 | **Dependencies (one, vendored, not installed):** `package.json` `dependencies` | |
| 1010 | stays empty — the preview has no install step, so the files are committed under | |
| 1011 | `src/web/og/vendor/`, unmodified. | |
| 1012 | ||
| 1013 | - *resvg* (`@resvg/resvg-wasm` 2.6.2, `resvg.mjs` + `resvg.wasm`): an SVG | |
| 1014 | renderer compiled to WebAssembly, so no native binary or platform build. Why: | |
| 1015 | rasterizing text and gradients correctly is not a 20-line file. 2.4 MB wasm + | |
| 1016 | 17 KB glue. Licence MPL-2.0. | |
| 1017 | - *DejaVu Sans, Sans Bold, Sans Mono, Sans Mono Bold* (2.37): the fonts the PNG | |
| 1018 | is drawn in, so its text is the same on every host (the SVG names system | |
| 1019 | fonts; the card clamps text with a deliberately wide estimate, which these | |
| 1020 | fit). 2.0 MB. Free licence, `vendor/DEJAVU-LICENSE`. | |
| 1021 | ||
| 1022 | Not done: the SVG route is unchanged and still the card's source of truth; the | |
| 1023 | rasterizer runs on the main thread (about 100 ms once per distinct card). |
scripts/gate.ts +1
| @@ -67,6 +67,7 @@ export const GATE: readonly string[] = [ | ||
| 67 | 67 | "test/claim-link.test.ts", |
| 68 | 68 | "test/og-card.test.ts", |
| 69 | 69 | "test/og-route.test.ts", |
| 70 | "test/og-png.test.ts", | |
| 70 | 71 | "test/router.test.ts", |
| 71 | 72 | "test/health.test.ts", |
| 72 | 73 | "test/client-address.test.ts", |
src/http/server.ts +5
| @@ -1061,6 +1061,11 @@ export function buildRouter(): Router<Handler> { | ||
| 1061 | 1061 | router.add("GET", "/og/issue/:ref.svg", ogCard("issue")); |
| 1062 | 1062 | router.add("GET", "/og/pull/:ref.svg", ogCard("pull")); |
| 1063 | 1063 | router.add("GET", "/og/run/:ref.svg", ogCard("run")); |
| 1064 | router.add("GET", "/og/repo/:ref.png", ogCard("repo", "png")); | |
| 1065 | router.add("GET", "/og/app/:ref.png", ogCard("app", "png")); | |
| 1066 | router.add("GET", "/og/issue/:ref.png", ogCard("issue", "png")); | |
| 1067 | router.add("GET", "/og/pull/:ref.png", ogCard("pull", "png")); | |
| 1068 | router.add("GET", "/og/run/:ref.png", ogCard("run", "png")); | |
| 1064 | 1069 | // The pictures of an environment, read like the repository. |
| 1065 | 1070 | router.add("GET", "/:owner/:name/looks/:env/:commit/:kind.png", lookImage); |
| 1066 | 1071 |
src/web/og/png-cache.ts added +117
| @@ -0,0 +1,117 @@ | ||
| 1 | /** | |
| 2 | * PNG cards on disk: one file per sha256 of the card's inputs, under the | |
| 3 | * app's data directory, so a card is rasterized once however many times it | |
| 4 | * is asked for. The cache holds at most `OG_PNG_CAP` files; past that the | |
| 5 | * least recently used go. Concurrent first requests share one render, cold | |
| 6 | * renders run one at a time, and past `OG_PNG_QUEUE` waiting the cache refuses | |
| 7 | * (a throw, which the route answers as 503) so a crawl cannot hold the loop. | |
| 8 | */ | |
| 9 | ||
| 10 | import { createHash } from "node:crypto"; | |
| 11 | import { mkdir, readdir, rename, stat, unlink, utimes, writeFile } from "node:fs/promises"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import type { CardInput } from "./card.ts"; | |
| 14 | import type { Rasterizer } from "./raster.ts"; | |
| 15 | ||
| 16 | export const OG_PNG_CAP = 512; | |
| 17 | export const OG_PNG_QUEUE = 4; | |
| 18 | /** Bump when the card's look or the fonts change: old files are then unreachable. */ | |
| 19 | const LOOK = "1"; | |
| 20 | ||
| 21 | /** Thrown when too many cards wait; working as designed, not a failure. */ | |
| 22 | export class Busy extends Error {} | |
| 23 | ||
| 24 | export function cardKey(input: CardInput): string { | |
| 25 | return createHash("sha256").update(`${LOOK}\n${JSON.stringify(input)}`).digest("hex"); | |
| 26 | } | |
| 27 | ||
| 28 | export interface PngCache { | |
| 29 | /** The card's PNG; throws when it cannot be rasterized. */ | |
| 30 | get(input: CardInput): Promise<Uint8Array>; | |
| 31 | } | |
| 32 | ||
| 33 | export function pngCache(opts: { | |
| 34 | dir: string; | |
| 35 | raster: () => Promise<Rasterizer>; | |
| 36 | svg: (input: CardInput) => string; | |
| 37 | cap?: number; | |
| 38 | queue?: number; | |
| 39 | }): PngCache { | |
| 40 | const cap = opts.cap ?? OG_PNG_CAP; | |
| 41 | const maxQueue = opts.queue ?? OG_PNG_QUEUE; | |
| 42 | /** Cold renders running or waiting; `tail` ends when the last of them does. */ | |
| 43 | let queued = 0; | |
| 44 | let tail: Promise<unknown> = Promise.resolve(); | |
| 45 | /** key → seen order; the first entry is the least recently used. */ | |
| 46 | const lru = new Map<string, true>(); | |
| 47 | const pending = new Map<string, Promise<Uint8Array>>(); | |
| 48 | let scanned: Promise<void> | undefined; | |
| 49 | ||
| 50 | const path = (key: string) => join(opts.dir, `${key}.png`); | |
| 51 | ||
| 52 | const scan = () => | |
| 53 | (scanned ??= (async () => { | |
| 54 | await mkdir(opts.dir, { recursive: true }); | |
| 55 | const found: [string, number][] = []; | |
| 56 | for (const name of await readdir(opts.dir)) { | |
| 57 | if (!/^[0-9a-f]{64}\.png$/.test(name)) continue; | |
| 58 | const s = await stat(join(opts.dir, name)).catch(() => undefined); | |
| 59 | if (s) found.push([name.slice(0, 64), s.mtimeMs]); | |
| 60 | } | |
| 61 | for (const [key] of found.sort((a, b) => a[1] - b[1])) lru.set(key, true); | |
| 62 | await trim(); | |
outdated
Synchronous wasm render, about 100 ms, on the main thread for any anonymous request for a public card not yet cached. |
||
| 63 | })()); | |
| 64 | ||
| 65 | async function trim(): Promise<void> { | |
| 66 | while (lru.size > cap) { | |
| 67 | const oldest = lru.keys().next().value as string; | |
| 68 | lru.delete(oldest); | |
| 69 | await unlink(path(oldest)).catch(() => {}); | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | async function make(key: string, input: CardInput): Promise<Uint8Array> { | |
| 74 | const png = (await opts.raster())(opts.svg(input)); | |
Not blocking: this render blocks the event loop for about 100 ms. The serial queue limits how much runs at once, but not how long it goes on. Anonymous GETs have no rate limit (the limiter keys on a credential), and this forge has more public issues and runs than |
||
| 75 | const tmp = path(key) + `.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`; | |
| 76 | // The directory may have been removed since the scan. | |
| 77 | await mkdir(opts.dir, { recursive: true }); | |
| 78 | try { | |
| 79 | await writeFile(tmp, png); | |
| 80 | await rename(tmp, path(key)); | |
| 81 | } catch (error) { | |
| 82 | await unlink(tmp).catch(() => {}); | |
| 83 | throw error; | |
| 84 | } | |
| 85 | lru.set(key, true); | |
| 86 | await trim(); | |
| 87 | return png; | |
| 88 | } | |
| 89 | ||
| 90 | return { | |
| 91 | async get(input) { | |
| 92 | const key = cardKey(input); | |
| 93 | await scan(); | |
| 94 | const hit = Bun.file(path(key)); | |
| 95 | if (await hit.exists()) { | |
| 96 | lru.delete(key); | |
| 97 | lru.set(key, true); | |
| 98 | const now = new Date(); | |
| 99 | utimes(path(key), now, now).catch(() => {}); | |
Minor: a concurrent |
||
| 100 | return new Uint8Array(await hit.arrayBuffer()); | |
| 101 | } | |
| 102 | let job = pending.get(key); | |
| 103 | if (!job) { | |
| 104 | if (queued >= maxQueue) throw new Busy("too many cards waiting to be rasterized"); | |
| 105 | queued++; | |
| 106 | const run = tail.then(() => make(key, input)); | |
| 107 | tail = run.catch(() => {}); | |
| 108 | job = run.finally(() => { | |
| 109 | queued--; | |
| 110 | pending.delete(key); | |
| 111 | }); | |
| 112 | pending.set(key, job); | |
| 113 | } | |
| 114 | return job; | |
| 115 | }, | |
| 116 | }; | |
| 117 | } |
src/web/og/raster.ts added +72
| @@ -0,0 +1,72 @@ | ||
| 1 | /** | |
| 2 | * The one rasterizer: resvg compiled to WebAssembly, vendored under | |
| 3 | * `./vendor/` with the DejaVu fonts the card is drawn in (see the README's | |
| 4 | * dependency list). Nothing is loaded at import: the first PNG asks for it, | |
| 5 | * so a forge that cannot load it still boots and answers everything else. | |
| 6 | * A failed load is remembered for RETRY_MS and then tried again. | |
| 7 | */ | |
| 8 | ||
| 9 | import { join } from "node:path"; | |
| 10 | ||
| 11 | export type Rasterizer = (svg: string) => Uint8Array; | |
| 12 | ||
| 13 | const RETRY_MS = 30_000; | |
| 14 | const VENDOR = join(import.meta.dir, "vendor"); | |
| 15 | const FONTS = ["DejaVuSans.ttf", "DejaVuSans-Bold.ttf", "DejaVuSansMono.ttf", "DejaVuSansMono-Bold.ttf"]; | |
| 16 | ||
| 17 | /** The wasm can be initialised once per process; a retried load reuses it. */ | |
| 18 | let wasm: Promise<typeof import("./vendor/resvg.mjs")> | undefined; | |
| 19 | ||
| 20 | function initOnce() { | |
| 21 | return (wasm ??= (async () => { | |
| 22 | const mod = await import("./vendor/resvg.mjs"); | |
| 23 | await mod.initWasm(await Bun.file(join(VENDOR, "resvg.wasm")).arrayBuffer()); | |
| 24 | return mod; | |
| 25 | })().catch((error) => { | |
| 26 | wasm = undefined; | |
| 27 | throw error; | |
| 28 | })); | |
| 29 | } | |
| 30 | ||
| 31 | async function load(): Promise<Rasterizer> { | |
| 32 | const { Resvg } = await initOnce(); | |
| 33 | const fontBuffers = await Promise.all( | |
| 34 | FONTS.map(async (f) => new Uint8Array(await Bun.file(join(VENDOR, f)).arrayBuffer())), | |
| 35 | ); | |
| 36 | const font = { | |
| 37 | fontBuffers, | |
| 38 | defaultFontFamily: "DejaVu Sans", | |
| 39 | sansSerifFamily: "DejaVu Sans", | |
| 40 | monospaceFamily: "DejaVu Sans Mono", | |
| 41 | }; | |
| 42 | return (svg) => { | |
| 43 | const r = new Resvg(svg, { font, fitTo: { mode: "original" } }); | |
| 44 | try { | |
| 45 | const img = r.render(); | |
| 46 | try { | |
| 47 | return img.asPng(); | |
| 48 | } finally { | |
| 49 | img.free(); | |
| 50 | } | |
| 51 | } finally { | |
| 52 | r.free(); | |
| 53 | } | |
| 54 | }; | |
| 55 | } | |
| 56 | ||
| 57 | let loading: Promise<Rasterizer> | undefined; | |
| 58 | let loader: () => Promise<Rasterizer> = load; | |
| 59 | ||
| 60 | export function rasterizer(): Promise<Rasterizer> { | |
| 61 | if (!loading) { | |
| 62 | const mine = (loading = loader()); | |
| 63 | mine.catch(() => setTimeout(() => loading === mine && (loading = undefined), RETRY_MS).unref()); | |
| 64 | } | |
| 65 | return loading; | |
| 66 | } | |
| 67 | ||
| 68 | /** For tests: stand in for the loader (or restore it) and forget what was loaded. */ | |
| 69 | export function useLoader(next?: () => Promise<Rasterizer>): void { | |
| 70 | loader = next ?? load; | |
| 71 | loading = undefined; | |
| 72 | } |
src/web/og/vendor/DEJAVU-LICENSE added +187
| @@ -0,0 +1,187 @@ | ||
| 1 | Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. | |
| 2 | Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below) | |
| 3 | ||
| 4 | ||
| 5 | Bitstream Vera Fonts Copyright | |
| 6 | ------------------------------ | |
| 7 | ||
| 8 | Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is | |
| 9 | a trademark of Bitstream, Inc. | |
| 10 | ||
| 11 | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 12 | of the fonts accompanying this license ("Fonts") and associated | |
| 13 | documentation files (the "Font Software"), to reproduce and distribute the | |
| 14 | Font Software, including without limitation the rights to use, copy, merge, | |
| 15 | publish, distribute, and/or sell copies of the Font Software, and to permit | |
| 16 | persons to whom the Font Software is furnished to do so, subject to the | |
| 17 | following conditions: | |
| 18 | ||
| 19 | The above copyright and trademark notices and this permission notice shall | |
| 20 | be included in all copies of one or more of the Font Software typefaces. | |
| 21 | ||
| 22 | The Font Software may be modified, altered, or added to, and in particular | |
| 23 | the designs of glyphs or characters in the Fonts may be modified and | |
| 24 | additional glyphs or characters may be added to the Fonts, only if the fonts | |
| 25 | are renamed to names not containing either the words "Bitstream" or the word | |
| 26 | "Vera". | |
| 27 | ||
| 28 | This License becomes null and void to the extent applicable to Fonts or Font | |
| 29 | Software that has been modified and is distributed under the "Bitstream | |
| 30 | Vera" names. | |
| 31 | ||
| 32 | The Font Software may be sold as part of a larger software package but no | |
| 33 | copy of one or more of the Font Software typefaces may be sold by itself. | |
| 34 | ||
| 35 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS | |
| 36 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, | |
| 37 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, | |
| 38 | TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME | |
| 39 | FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING | |
| 40 | ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, | |
| 41 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF | |
| 42 | THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE | |
| 43 | FONT SOFTWARE. | |
| 44 | ||
| 45 | Except as contained in this notice, the names of Gnome, the Gnome | |
| 46 | Foundation, and Bitstream Inc., shall not be used in advertising or | |
| 47 | otherwise to promote the sale, use or other dealings in this Font Software | |
| 48 | without prior written authorization from the Gnome Foundation or Bitstream | |
| 49 | Inc., respectively. For further information, contact: fonts at gnome dot | |
| 50 | org. | |
| 51 | ||
| 52 | Arev Fonts Copyright | |
| 53 | ------------------------------ | |
| 54 | ||
| 55 | Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. | |
| 56 | ||
| 57 | Permission is hereby granted, free of charge, to any person obtaining | |
| 58 | a copy of the fonts accompanying this license ("Fonts") and | |
| 59 | associated documentation files (the "Font Software"), to reproduce | |
| 60 | and distribute the modifications to the Bitstream Vera Font Software, | |
| 61 | including without limitation the rights to use, copy, merge, publish, | |
| 62 | distribute, and/or sell copies of the Font Software, and to permit | |
| 63 | persons to whom the Font Software is furnished to do so, subject to | |
| 64 | the following conditions: | |
| 65 | ||
| 66 | The above copyright and trademark notices and this permission notice | |
| 67 | shall be included in all copies of one or more of the Font Software | |
| 68 | typefaces. | |
| 69 | ||
| 70 | The Font Software may be modified, altered, or added to, and in | |
| 71 | particular the designs of glyphs or characters in the Fonts may be | |
| 72 | modified and additional glyphs or characters may be added to the | |
| 73 | Fonts, only if the fonts are renamed to names not containing either | |
| 74 | the words "Tavmjong Bah" or the word "Arev". | |
| 75 | ||
| 76 | This License becomes null and void to the extent applicable to Fonts | |
| 77 | or Font Software that has been modified and is distributed under the | |
| 78 | "Tavmjong Bah Arev" names. | |
| 79 | ||
| 80 | The Font Software may be sold as part of a larger software package but | |
| 81 | no copy of one or more of the Font Software typefaces may be sold by | |
| 82 | itself. | |
| 83 | ||
| 84 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
| 85 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF | |
| 86 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT | |
| 87 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL | |
| 88 | TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, | |
| 89 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL | |
| 90 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING | |
| 91 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM | |
| 92 | OTHER DEALINGS IN THE FONT SOFTWARE. | |
| 93 | ||
| 94 | Except as contained in this notice, the name of Tavmjong Bah shall not | |
| 95 | be used in advertising or otherwise to promote the sale, use or other | |
| 96 | dealings in this Font Software without prior written authorization | |
| 97 | from Tavmjong Bah. For further information, contact: tavmjong @ free | |
| 98 | . fr. | |
| 99 | ||
| 100 | TeX Gyre DJV Math | |
| 101 | ----------------- | |
| 102 | Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. | |
| 103 | ||
| 104 | Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski | |
| 105 | (on behalf of TeX users groups) are in public domain. | |
| 106 | ||
| 107 | Letters imported from Euler Fraktur from AMSfonts are (c) American | |
| 108 | Mathematical Society (see below). | |
| 109 | Bitstream Vera Fonts Copyright | |
| 110 | Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera | |
| 111 | is a trademark of Bitstream, Inc. | |
| 112 | ||
| 113 | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 114 | of the fonts accompanying this license (“Fonts”) and associated | |
| 115 | documentation | |
| 116 | files (the “Font Software”), to reproduce and distribute the Font Software, | |
| 117 | including without limitation the rights to use, copy, merge, publish, | |
| 118 | distribute, | |
| 119 | and/or sell copies of the Font Software, and to permit persons to whom | |
| 120 | the Font Software is furnished to do so, subject to the following | |
| 121 | conditions: | |
| 122 | ||
| 123 | The above copyright and trademark notices and this permission notice | |
| 124 | shall be | |
| 125 | included in all copies of one or more of the Font Software typefaces. | |
| 126 | ||
| 127 | The Font Software may be modified, altered, or added to, and in particular | |
| 128 | the designs of glyphs or characters in the Fonts may be modified and | |
| 129 | additional | |
| 130 | glyphs or characters may be added to the Fonts, only if the fonts are | |
| 131 | renamed | |
| 132 | to names not containing either the words “Bitstream” or the word “Vera”. | |
| 133 | ||
| 134 | This License becomes null and void to the extent applicable to Fonts or | |
| 135 | Font Software | |
| 136 | that has been modified and is distributed under the “Bitstream Vera” | |
| 137 | names. | |
| 138 | ||
| 139 | The Font Software may be sold as part of a larger software package but | |
| 140 | no copy | |
| 141 | of one or more of the Font Software typefaces may be sold by itself. | |
| 142 | ||
| 143 | THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS | |
| 144 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, | |
| 145 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, | |
| 146 | TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME | |
| 147 | FOUNDATION | |
| 148 | BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, | |
| 149 | SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN | |
| 150 | ACTION | |
| 151 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR | |
| 152 | INABILITY TO USE | |
| 153 | THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. | |
| 154 | Except as contained in this notice, the names of GNOME, the GNOME | |
| 155 | Foundation, | |
| 156 | and Bitstream Inc., shall not be used in advertising or otherwise to promote | |
| 157 | the sale, use or other dealings in this Font Software without prior written | |
| 158 | authorization from the GNOME Foundation or Bitstream Inc., respectively. | |
| 159 | For further information, contact: fonts at gnome dot org. | |
| 160 | ||
| 161 | AMSFonts (v. 2.2) copyright | |
| 162 | ||
| 163 | The PostScript Type 1 implementation of the AMSFonts produced by and | |
| 164 | previously distributed by Blue Sky Research and Y&Y, Inc. are now freely | |
| 165 | available for general use. This has been accomplished through the | |
| 166 | cooperation | |
| 167 | of a consortium of scientific publishers with Blue Sky Research and Y&Y. | |
| 168 | Members of this consortium include: | |
| 169 | ||
| 170 | Elsevier Science IBM Corporation Society for Industrial and Applied | |
| 171 | Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS) | |
| 172 | ||
| 173 | In order to assure the authenticity of these fonts, copyright will be | |
| 174 | held by | |
| 175 | the American Mathematical Society. This is not meant to restrict in any way | |
| 176 | the legitimate use of the fonts, such as (but not limited to) electronic | |
| 177 | distribution of documents containing these fonts, inclusion of these fonts | |
| 178 | into other public domain or commercial font collections or computer | |
| 179 | applications, use of the outline data to create derivative fonts and/or | |
| 180 | faces, etc. However, the AMS does require that the AMS copyright notice be | |
| 181 | removed from any derivative versions of the fonts which have been altered in | |
| 182 | any way. In addition, to ensure the fidelity of TeX documents using Computer | |
| 183 | Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces, | |
| 184 | has requested that any alterations which yield different font metrics be | |
| 185 | given a different name. | |
| 186 | ||
| 187 | $Id$ |
src/web/og/vendor/DejaVuSans-Bold.ttf added
src/web/og/vendor/DejaVuSans.ttf added
src/web/og/vendor/DejaVuSansMono-Bold.ttf added
src/web/og/vendor/DejaVuSansMono.ttf added
src/web/og/vendor/resvg.d.mts added +109
| @@ -0,0 +1,109 @@ | ||
| 1 | // Generated by dts-bundle-generator v9.3.1 | |
| 2 | ||
| 3 | declare class BBox { | |
| 4 | free(): void; | |
| 5 | /** | |
| 6 | */ | |
| 7 | height: number; | |
| 8 | /** | |
| 9 | */ | |
| 10 | width: number; | |
| 11 | /** | |
| 12 | */ | |
| 13 | x: number; | |
| 14 | /** | |
| 15 | */ | |
| 16 | y: number; | |
| 17 | } | |
| 18 | declare class RenderedImage { | |
| 19 | free(): void; | |
| 20 | /** | |
| 21 | * Write the image data to Uint8Array | |
| 22 | * @returns {Uint8Array} | |
| 23 | */ | |
| 24 | asPng(): Uint8Array; | |
| 25 | /** | |
| 26 | * Get the PNG height | |
| 27 | */ | |
| 28 | readonly height: number; | |
| 29 | /** | |
| 30 | * Get the RGBA pixels of the image | |
| 31 | */ | |
| 32 | readonly pixels: Uint8Array; | |
| 33 | /** | |
| 34 | * Get the PNG width | |
| 35 | */ | |
| 36 | readonly width: number; | |
| 37 | } | |
| 38 | export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; | |
| 39 | export type ResvgRenderOptions = { | |
| 40 | font?: SystemFontsOptions | CustomFontsOptions; | |
| 41 | dpi?: number; | |
| 42 | languages?: string[]; | |
| 43 | shapeRendering?: 0 // optimizeSpeed | |
| 44 | | 1 // crispEdges | |
| 45 | | 2; // geometricPrecision | |
| 46 | textRendering?: 0 // optimizeSpeed | |
| 47 | | 1 // optimizeLegibility | |
| 48 | | 2; // geometricPrecision' | |
| 49 | imageRendering?: 0 // optimizeQuality | |
| 50 | | 1; // optimizeSpeed | |
| 51 | fitTo?: { | |
| 52 | mode: "original"; | |
| 53 | } | { | |
| 54 | mode: "width"; | |
| 55 | value: number; | |
| 56 | } | { | |
| 57 | mode: "height"; | |
| 58 | value: number; | |
| 59 | } | { | |
| 60 | mode: "zoom"; | |
| 61 | value: number; | |
| 62 | }; | |
| 63 | background?: string; // Support CSS3 color, e.g. rgba(255, 255, 255, .8) | |
| 64 | crop?: { | |
| 65 | left: number; | |
| 66 | top: number; | |
| 67 | right?: number; | |
| 68 | bottom?: number; | |
| 69 | }; | |
| 70 | }; | |
| 71 | export type FontOptions = { | |
| 72 | defaultFontSize?: number; // Default: 12 | |
| 73 | defaultFontFamily?: string; | |
| 74 | serifFamily?: string; | |
| 75 | sansSerifFamily?: string; | |
| 76 | cursiveFamily?: string; | |
| 77 | fantasyFamily?: string; | |
| 78 | monospaceFamily?: string; | |
| 79 | }; | |
| 80 | export type CustomFontsOptions = { | |
| 81 | fontBuffers: Uint8Array[]; // A list of raw font buffers to load. | |
| 82 | } & FontOptions; | |
| 83 | export type SystemFontsOptions = { | |
| 84 | loadSystemFonts?: boolean; // Default: true. if set to false, it will be faster. | |
| 85 | fontFiles?: string[]; // A list of local font file paths to load. | |
| 86 | fontDirs?: string[]; // A list of local font directories to load. | |
| 87 | } & FontOptions; | |
| 88 | /** | |
| 89 | * Initialize Wasm module | |
| 90 | * @param module_or_path WebAssembly Module or .wasm url | |
| 91 | * | |
| 92 | */ | |
| 93 | export declare const initWasm: (module_or_path: Promise<InitInput> | InitInput) => Promise<void>; | |
| 94 | export declare const Resvg: { | |
| 95 | new (svg: Uint8Array | string, options?: ResvgRenderOptions): { | |
| 96 | free(): void; | |
| 97 | render(): RenderedImage; | |
| 98 | toString(): string; | |
| 99 | innerBBox(): BBox | undefined; | |
| 100 | getBBox(): BBox | undefined; | |
| 101 | cropByBBox(bbox: BBox): void; | |
| 102 | imagesToResolve(): any[]; | |
| 103 | resolveImage(href: string, buffer: Uint8Array): void; | |
| 104 | readonly height: number; | |
| 105 | readonly width: number; | |
| 106 | }; | |
| 107 | }; | |
| 108 | ||
| 109 | export {}; |
src/web/og/vendor/resvg.mjs added +584
| @@ -0,0 +1,584 @@ | ||
| 1 | // wasm/dist/index.js | |
| 2 | var wasm; | |
| 3 | var heap = new Array(128).fill(void 0); | |
| 4 | heap.push(void 0, null, true, false); | |
| 5 | var heap_next = heap.length; | |
| 6 | function addHeapObject(obj) { | |
| 7 | if (heap_next === heap.length) | |
| 8 | heap.push(heap.length + 1); | |
| 9 | const idx = heap_next; | |
| 10 | heap_next = heap[idx]; | |
| 11 | heap[idx] = obj; | |
| 12 | return idx; | |
| 13 | } | |
| 14 | function getObject(idx) { | |
| 15 | return heap[idx]; | |
| 16 | } | |
| 17 | function dropObject(idx) { | |
| 18 | if (idx < 132) | |
| 19 | return; | |
| 20 | heap[idx] = heap_next; | |
| 21 | heap_next = idx; | |
| 22 | } | |
| 23 | function takeObject(idx) { | |
| 24 | const ret = getObject(idx); | |
| 25 | dropObject(idx); | |
| 26 | return ret; | |
| 27 | } | |
| 28 | var WASM_VECTOR_LEN = 0; | |
| 29 | var cachedUint8Memory0 = null; | |
| 30 | function getUint8Memory0() { | |
| 31 | if (cachedUint8Memory0 === null || cachedUint8Memory0.byteLength === 0) { | |
| 32 | cachedUint8Memory0 = new Uint8Array(wasm.memory.buffer); | |
| 33 | } | |
| 34 | return cachedUint8Memory0; | |
| 35 | } | |
| 36 | var cachedTextEncoder = typeof TextEncoder !== "undefined" ? new TextEncoder("utf-8") : { encode: () => { | |
| 37 | throw Error("TextEncoder not available"); | |
| 38 | } }; | |
| 39 | var encodeString = typeof cachedTextEncoder.encodeInto === "function" ? function(arg, view) { | |
| 40 | return cachedTextEncoder.encodeInto(arg, view); | |
| 41 | } : function(arg, view) { | |
| 42 | const buf = cachedTextEncoder.encode(arg); | |
| 43 | view.set(buf); | |
| 44 | return { | |
| 45 | read: arg.length, | |
| 46 | written: buf.length | |
| 47 | }; | |
| 48 | }; | |
| 49 | function passStringToWasm0(arg, malloc, realloc) { | |
| 50 | if (realloc === void 0) { | |
| 51 | const buf = cachedTextEncoder.encode(arg); | |
| 52 | const ptr2 = malloc(buf.length, 1) >>> 0; | |
| 53 | getUint8Memory0().subarray(ptr2, ptr2 + buf.length).set(buf); | |
| 54 | WASM_VECTOR_LEN = buf.length; | |
| 55 | return ptr2; | |
| 56 | } | |
| 57 | let len = arg.length; | |
| 58 | let ptr = malloc(len, 1) >>> 0; | |
| 59 | const mem = getUint8Memory0(); | |
| 60 | let offset = 0; | |
| 61 | for (; offset < len; offset++) { | |
| 62 | const code = arg.charCodeAt(offset); | |
| 63 | if (code > 127) | |
| 64 | break; | |
| 65 | mem[ptr + offset] = code; | |
| 66 | } | |
| 67 | if (offset !== len) { | |
| 68 | if (offset !== 0) { | |
| 69 | arg = arg.slice(offset); | |
| 70 | } | |
| 71 | ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; | |
| 72 | const view = getUint8Memory0().subarray(ptr + offset, ptr + len); | |
| 73 | const ret = encodeString(arg, view); | |
| 74 | offset += ret.written; | |
| 75 | ptr = realloc(ptr, len, offset, 1) >>> 0; | |
| 76 | } | |
| 77 | WASM_VECTOR_LEN = offset; | |
| 78 | return ptr; | |
| 79 | } | |
| 80 | function isLikeNone(x) { | |
| 81 | return x === void 0 || x === null; | |
| 82 | } | |
| 83 | var cachedInt32Memory0 = null; | |
| 84 | function getInt32Memory0() { | |
| 85 | if (cachedInt32Memory0 === null || cachedInt32Memory0.byteLength === 0) { | |
| 86 | cachedInt32Memory0 = new Int32Array(wasm.memory.buffer); | |
| 87 | } | |
| 88 | return cachedInt32Memory0; | |
| 89 | } | |
| 90 | var cachedTextDecoder = typeof TextDecoder !== "undefined" ? new TextDecoder("utf-8", { ignoreBOM: true, fatal: true }) : { decode: () => { | |
| 91 | throw Error("TextDecoder not available"); | |
| 92 | } }; | |
| 93 | if (typeof TextDecoder !== "undefined") { | |
| 94 | cachedTextDecoder.decode(); | |
| 95 | } | |
| 96 | function getStringFromWasm0(ptr, len) { | |
| 97 | ptr = ptr >>> 0; | |
| 98 | return cachedTextDecoder.decode(getUint8Memory0().subarray(ptr, ptr + len)); | |
| 99 | } | |
| 100 | function _assertClass(instance, klass) { | |
| 101 | if (!(instance instanceof klass)) { | |
| 102 | throw new Error(`expected instance of ${klass.name}`); | |
| 103 | } | |
| 104 | return instance.ptr; | |
| 105 | } | |
| 106 | function handleError(f, args) { | |
| 107 | try { | |
| 108 | return f.apply(this, args); | |
| 109 | } catch (e) { | |
| 110 | wasm.__wbindgen_exn_store(addHeapObject(e)); | |
| 111 | } | |
| 112 | } | |
| 113 | var BBoxFinalization = typeof FinalizationRegistry === "undefined" ? { register: () => { | |
| 114 | }, unregister: () => { | |
| 115 | } } : new FinalizationRegistry((ptr) => wasm.__wbg_bbox_free(ptr >>> 0)); | |
| 116 | var BBox = class _BBox { | |
| 117 | static __wrap(ptr) { | |
| 118 | ptr = ptr >>> 0; | |
| 119 | const obj = Object.create(_BBox.prototype); | |
| 120 | obj.__wbg_ptr = ptr; | |
| 121 | BBoxFinalization.register(obj, obj.__wbg_ptr, obj); | |
| 122 | return obj; | |
| 123 | } | |
| 124 | __destroy_into_raw() { | |
| 125 | const ptr = this.__wbg_ptr; | |
| 126 | this.__wbg_ptr = 0; | |
| 127 | BBoxFinalization.unregister(this); | |
| 128 | return ptr; | |
| 129 | } | |
| 130 | free() { | |
| 131 | const ptr = this.__destroy_into_raw(); | |
| 132 | wasm.__wbg_bbox_free(ptr); | |
| 133 | } | |
| 134 | /** | |
| 135 | * @returns {number} | |
| 136 | */ | |
| 137 | get x() { | |
| 138 | const ret = wasm.__wbg_get_bbox_x(this.__wbg_ptr); | |
| 139 | return ret; | |
| 140 | } | |
| 141 | /** | |
| 142 | * @param {number} arg0 | |
| 143 | */ | |
| 144 | set x(arg0) { | |
| 145 | wasm.__wbg_set_bbox_x(this.__wbg_ptr, arg0); | |
| 146 | } | |
| 147 | /** | |
| 148 | * @returns {number} | |
| 149 | */ | |
| 150 | get y() { | |
| 151 | const ret = wasm.__wbg_get_bbox_y(this.__wbg_ptr); | |
| 152 | return ret; | |
| 153 | } | |
| 154 | /** | |
| 155 | * @param {number} arg0 | |
| 156 | */ | |
| 157 | set y(arg0) { | |
| 158 | wasm.__wbg_set_bbox_y(this.__wbg_ptr, arg0); | |
| 159 | } | |
| 160 | /** | |
| 161 | * @returns {number} | |
| 162 | */ | |
| 163 | get width() { | |
| 164 | const ret = wasm.__wbg_get_bbox_width(this.__wbg_ptr); | |
| 165 | return ret; | |
| 166 | } | |
| 167 | /** | |
| 168 | * @param {number} arg0 | |
| 169 | */ | |
| 170 | set width(arg0) { | |
| 171 | wasm.__wbg_set_bbox_width(this.__wbg_ptr, arg0); | |
| 172 | } | |
| 173 | /** | |
| 174 | * @returns {number} | |
| 175 | */ | |
| 176 | get height() { | |
| 177 | const ret = wasm.__wbg_get_bbox_height(this.__wbg_ptr); | |
| 178 | return ret; | |
| 179 | } | |
| 180 | /** | |
| 181 | * @param {number} arg0 | |
| 182 | */ | |
| 183 | set height(arg0) { | |
| 184 | wasm.__wbg_set_bbox_height(this.__wbg_ptr, arg0); | |
| 185 | } | |
| 186 | }; | |
| 187 | var RenderedImageFinalization = typeof FinalizationRegistry === "undefined" ? { register: () => { | |
| 188 | }, unregister: () => { | |
| 189 | } } : new FinalizationRegistry((ptr) => wasm.__wbg_renderedimage_free(ptr >>> 0)); | |
| 190 | var RenderedImage = class _RenderedImage { | |
| 191 | static __wrap(ptr) { | |
| 192 | ptr = ptr >>> 0; | |
| 193 | const obj = Object.create(_RenderedImage.prototype); | |
| 194 | obj.__wbg_ptr = ptr; | |
| 195 | RenderedImageFinalization.register(obj, obj.__wbg_ptr, obj); | |
| 196 | return obj; | |
| 197 | } | |
| 198 | __destroy_into_raw() { | |
| 199 | const ptr = this.__wbg_ptr; | |
| 200 | this.__wbg_ptr = 0; | |
| 201 | RenderedImageFinalization.unregister(this); | |
| 202 | return ptr; | |
| 203 | } | |
| 204 | free() { | |
| 205 | const ptr = this.__destroy_into_raw(); | |
| 206 | wasm.__wbg_renderedimage_free(ptr); | |
| 207 | } | |
| 208 | /** | |
| 209 | * Get the PNG width | |
| 210 | * @returns {number} | |
| 211 | */ | |
| 212 | get width() { | |
| 213 | const ret = wasm.renderedimage_width(this.__wbg_ptr); | |
| 214 | return ret >>> 0; | |
| 215 | } | |
| 216 | /** | |
| 217 | * Get the PNG height | |
| 218 | * @returns {number} | |
| 219 | */ | |
| 220 | get height() { | |
| 221 | const ret = wasm.renderedimage_height(this.__wbg_ptr); | |
| 222 | return ret >>> 0; | |
| 223 | } | |
| 224 | /** | |
| 225 | * Write the image data to Uint8Array | |
| 226 | * @returns {Uint8Array} | |
| 227 | */ | |
| 228 | asPng() { | |
| 229 | try { | |
| 230 | const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); | |
| 231 | wasm.renderedimage_asPng(retptr, this.__wbg_ptr); | |
| 232 | var r0 = getInt32Memory0()[retptr / 4 + 0]; | |
| 233 | var r1 = getInt32Memory0()[retptr / 4 + 1]; | |
| 234 | var r2 = getInt32Memory0()[retptr / 4 + 2]; | |
| 235 | if (r2) { | |
| 236 | throw takeObject(r1); | |
| 237 | } | |
| 238 | return takeObject(r0); | |
| 239 | } finally { | |
| 240 | wasm.__wbindgen_add_to_stack_pointer(16); | |
| 241 | } | |
| 242 | } | |
| 243 | /** | |
| 244 | * Get the RGBA pixels of the image | |
| 245 | * @returns {Uint8Array} | |
| 246 | */ | |
| 247 | get pixels() { | |
| 248 | const ret = wasm.renderedimage_pixels(this.__wbg_ptr); | |
| 249 | return takeObject(ret); | |
| 250 | } | |
| 251 | }; | |
| 252 | var ResvgFinalization = typeof FinalizationRegistry === "undefined" ? { register: () => { | |
| 253 | }, unregister: () => { | |
| 254 | } } : new FinalizationRegistry((ptr) => wasm.__wbg_resvg_free(ptr >>> 0)); | |
| 255 | var Resvg = class { | |
| 256 | __destroy_into_raw() { | |
| 257 | const ptr = this.__wbg_ptr; | |
| 258 | this.__wbg_ptr = 0; | |
| 259 | ResvgFinalization.unregister(this); | |
| 260 | return ptr; | |
| 261 | } | |
| 262 | free() { | |
| 263 | const ptr = this.__destroy_into_raw(); | |
| 264 | wasm.__wbg_resvg_free(ptr); | |
| 265 | } | |
| 266 | /** | |
| 267 | * @param {Uint8Array | string} svg | |
| 268 | * @param {string | undefined} [options] | |
| 269 | * @param {Array<any> | undefined} [custom_font_buffers] | |
| 270 | */ | |
| 271 | constructor(svg, options, custom_font_buffers) { | |
| 272 | try { | |
| 273 | const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); | |
| 274 | var ptr0 = isLikeNone(options) ? 0 : passStringToWasm0(options, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); | |
| 275 | var len0 = WASM_VECTOR_LEN; | |
| 276 | wasm.resvg_new(retptr, addHeapObject(svg), ptr0, len0, isLikeNone(custom_font_buffers) ? 0 : addHeapObject(custom_font_buffers)); | |
| 277 | var r0 = getInt32Memory0()[retptr / 4 + 0]; | |
| 278 | var r1 = getInt32Memory0()[retptr / 4 + 1]; | |
| 279 | var r2 = getInt32Memory0()[retptr / 4 + 2]; | |
| 280 | if (r2) { | |
| 281 | throw takeObject(r1); | |
| 282 | } | |
| 283 | this.__wbg_ptr = r0 >>> 0; | |
| 284 | return this; | |
| 285 | } finally { | |
| 286 | wasm.__wbindgen_add_to_stack_pointer(16); | |
| 287 | } | |
| 288 | } | |
| 289 | /** | |
| 290 | * Get the SVG width | |
| 291 | * @returns {number} | |
| 292 | */ | |
| 293 | get width() { | |
| 294 | const ret = wasm.resvg_width(this.__wbg_ptr); | |
| 295 | return ret; | |
| 296 | } | |
| 297 | /** | |
| 298 | * Get the SVG height | |
| 299 | * @returns {number} | |
| 300 | */ | |
| 301 | get height() { | |
| 302 | const ret = wasm.resvg_height(this.__wbg_ptr); | |
| 303 | return ret; | |
| 304 | } | |
| 305 | /** | |
| 306 | * Renders an SVG in Wasm | |
| 307 | * @returns {RenderedImage} | |
| 308 | */ | |
| 309 | render() { | |
| 310 | try { | |
| 311 | const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); | |
| 312 | wasm.resvg_render(retptr, this.__wbg_ptr); | |
| 313 | var r0 = getInt32Memory0()[retptr / 4 + 0]; | |
| 314 | var r1 = getInt32Memory0()[retptr / 4 + 1]; | |
| 315 | var r2 = getInt32Memory0()[retptr / 4 + 2]; | |
| 316 | if (r2) { | |
| 317 | throw takeObject(r1); | |
| 318 | } | |
| 319 | return RenderedImage.__wrap(r0); | |
| 320 | } finally { | |
| 321 | wasm.__wbindgen_add_to_stack_pointer(16); | |
| 322 | } | |
| 323 | } | |
| 324 | /** | |
| 325 | * Output usvg-simplified SVG string | |
| 326 | * @returns {string} | |
| 327 | */ | |
| 328 | toString() { | |
| 329 | let deferred1_0; | |
| 330 | let deferred1_1; | |
| 331 | try { | |
| 332 | const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); | |
| 333 | wasm.resvg_toString(retptr, this.__wbg_ptr); | |
| 334 | var r0 = getInt32Memory0()[retptr / 4 + 0]; | |
| 335 | var r1 = getInt32Memory0()[retptr / 4 + 1]; | |
| 336 | deferred1_0 = r0; | |
| 337 | deferred1_1 = r1; | |
| 338 | return getStringFromWasm0(r0, r1); | |
| 339 | } finally { | |
| 340 | wasm.__wbindgen_add_to_stack_pointer(16); | |
| 341 | wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); | |
| 342 | } | |
| 343 | } | |
| 344 | /** | |
| 345 | * Calculate a maximum bounding box of all visible elements in this SVG. | |
| 346 | * | |
| 347 | * Note: path bounding box are approx values. | |
| 348 | * @returns {BBox | undefined} | |
| 349 | */ | |
| 350 | innerBBox() { | |
| 351 | const ret = wasm.resvg_innerBBox(this.__wbg_ptr); | |
| 352 | return ret === 0 ? void 0 : BBox.__wrap(ret); | |
| 353 | } | |
| 354 | /** | |
| 355 | * Calculate a maximum bounding box of all visible elements in this SVG. | |
| 356 | * This will first apply transform. | |
| 357 | * Similar to `SVGGraphicsElement.getBBox()` DOM API. | |
| 358 | * @returns {BBox | undefined} | |
| 359 | */ | |
| 360 | getBBox() { | |
| 361 | const ret = wasm.resvg_getBBox(this.__wbg_ptr); | |
| 362 | return ret === 0 ? void 0 : BBox.__wrap(ret); | |
| 363 | } | |
| 364 | /** | |
| 365 | * Use a given `BBox` to crop the svg. Currently this method simply changes | |
| 366 | * the viewbox/size of the svg and do not move the elements for simplicity | |
| 367 | * @param {BBox} bbox | |
| 368 | */ | |
| 369 | cropByBBox(bbox) { | |
| 370 | _assertClass(bbox, BBox); | |
| 371 | wasm.resvg_cropByBBox(this.__wbg_ptr, bbox.__wbg_ptr); | |
| 372 | } | |
| 373 | /** | |
| 374 | * @returns {Array<any>} | |
| 375 | */ | |
| 376 | imagesToResolve() { | |
| 377 | try { | |
| 378 | const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); | |
| 379 | wasm.resvg_imagesToResolve(retptr, this.__wbg_ptr); | |
| 380 | var r0 = getInt32Memory0()[retptr / 4 + 0]; | |
| 381 | var r1 = getInt32Memory0()[retptr / 4 + 1]; | |
| 382 | var r2 = getInt32Memory0()[retptr / 4 + 2]; | |
| 383 | if (r2) { | |
| 384 | throw takeObject(r1); | |
| 385 | } | |
| 386 | return takeObject(r0); | |
| 387 | } finally { | |
| 388 | wasm.__wbindgen_add_to_stack_pointer(16); | |
| 389 | } | |
| 390 | } | |
| 391 | /** | |
| 392 | * @param {string} href | |
| 393 | * @param {Uint8Array} buffer | |
| 394 | */ | |
| 395 | resolveImage(href, buffer) { | |
| 396 | try { | |
| 397 | const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); | |
| 398 | const ptr0 = passStringToWasm0(href, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); | |
| 399 | const len0 = WASM_VECTOR_LEN; | |
| 400 | wasm.resvg_resolveImage(retptr, this.__wbg_ptr, ptr0, len0, addHeapObject(buffer)); | |
| 401 | var r0 = getInt32Memory0()[retptr / 4 + 0]; | |
| 402 | var r1 = getInt32Memory0()[retptr / 4 + 1]; | |
| 403 | if (r1) { | |
| 404 | throw takeObject(r0); | |
| 405 | } | |
| 406 | } finally { | |
| 407 | wasm.__wbindgen_add_to_stack_pointer(16); | |
| 408 | } | |
| 409 | } | |
| 410 | }; | |
| 411 | async function __wbg_load(module, imports) { | |
| 412 | if (typeof Response === "function" && module instanceof Response) { | |
| 413 | if (typeof WebAssembly.instantiateStreaming === "function") { | |
| 414 | try { | |
| 415 | return await WebAssembly.instantiateStreaming(module, imports); | |
| 416 | } catch (e) { | |
| 417 | if (module.headers.get("Content-Type") != "application/wasm") { | |
| 418 | console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); | |
| 419 | } else { | |
| 420 | throw e; | |
| 421 | } | |
| 422 | } | |
| 423 | } | |
| 424 | const bytes = await module.arrayBuffer(); | |
| 425 | return await WebAssembly.instantiate(bytes, imports); | |
| 426 | } else { | |
| 427 | const instance = await WebAssembly.instantiate(module, imports); | |
| 428 | if (instance instanceof WebAssembly.Instance) { | |
| 429 | return { instance, module }; | |
| 430 | } else { | |
| 431 | return instance; | |
| 432 | } | |
| 433 | } | |
| 434 | } | |
| 435 | function __wbg_get_imports() { | |
| 436 | const imports = {}; | |
| 437 | imports.wbg = {}; | |
| 438 | imports.wbg.__wbg_new_28c511d9baebfa89 = function(arg0, arg1) { | |
| 439 | const ret = new Error(getStringFromWasm0(arg0, arg1)); | |
| 440 | return addHeapObject(ret); | |
| 441 | }; | |
| 442 | imports.wbg.__wbindgen_memory = function() { | |
| 443 | const ret = wasm.memory; | |
| 444 | return addHeapObject(ret); | |
| 445 | }; | |
| 446 | imports.wbg.__wbg_buffer_12d079cc21e14bdb = function(arg0) { | |
| 447 | const ret = getObject(arg0).buffer; | |
| 448 | return addHeapObject(ret); | |
| 449 | }; | |
| 450 | imports.wbg.__wbg_newwithbyteoffsetandlength_aa4a17c33a06e5cb = function(arg0, arg1, arg2) { | |
| 451 | const ret = new Uint8Array(getObject(arg0), arg1 >>> 0, arg2 >>> 0); | |
| 452 | return addHeapObject(ret); | |
| 453 | }; | |
| 454 | imports.wbg.__wbindgen_object_drop_ref = function(arg0) { | |
| 455 | takeObject(arg0); | |
| 456 | }; | |
| 457 | imports.wbg.__wbg_new_63b92bc8671ed464 = function(arg0) { | |
| 458 | const ret = new Uint8Array(getObject(arg0)); | |
| 459 | return addHeapObject(ret); | |
| 460 | }; | |
| 461 | imports.wbg.__wbg_values_839f3396d5aac002 = function(arg0) { | |
| 462 | const ret = getObject(arg0).values(); | |
| 463 | return addHeapObject(ret); | |
| 464 | }; | |
| 465 | imports.wbg.__wbg_next_196c84450b364254 = function() { | |
| 466 | return handleError(function(arg0) { | |
| 467 | const ret = getObject(arg0).next(); | |
| 468 | return addHeapObject(ret); | |
| 469 | }, arguments); | |
| 470 | }; | |
| 471 | imports.wbg.__wbg_done_298b57d23c0fc80c = function(arg0) { | |
| 472 | const ret = getObject(arg0).done; | |
| 473 | return ret; | |
| 474 | }; | |
| 475 | imports.wbg.__wbg_value_d93c65011f51a456 = function(arg0) { | |
| 476 | const ret = getObject(arg0).value; | |
| 477 | return addHeapObject(ret); | |
| 478 | }; | |
| 479 | imports.wbg.__wbg_instanceof_Uint8Array_2b3bbecd033d19f6 = function(arg0) { | |
| 480 | let result; | |
| 481 | try { | |
| 482 | result = getObject(arg0) instanceof Uint8Array; | |
| 483 | } catch (_) { | |
| 484 | result = false; | |
| 485 | } | |
| 486 | const ret = result; | |
| 487 | return ret; | |
| 488 | }; | |
| 489 | imports.wbg.__wbindgen_string_get = function(arg0, arg1) { | |
| 490 | const obj = getObject(arg1); | |
| 491 | const ret = typeof obj === "string" ? obj : void 0; | |
| 492 | var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); | |
| 493 | var len1 = WASM_VECTOR_LEN; | |
| 494 | getInt32Memory0()[arg0 / 4 + 1] = len1; | |
| 495 | getInt32Memory0()[arg0 / 4 + 0] = ptr1; | |
| 496 | }; | |
| 497 | imports.wbg.__wbg_new_16b304a2cfa7ff4a = function() { | |
| 498 | const ret = new Array(); | |
| 499 | return addHeapObject(ret); | |
| 500 | }; | |
| 501 | imports.wbg.__wbindgen_string_new = function(arg0, arg1) { | |
| 502 | const ret = getStringFromWasm0(arg0, arg1); | |
| 503 | return addHeapObject(ret); | |
| 504 | }; | |
| 505 | imports.wbg.__wbg_push_a5b05aedc7234f9f = function(arg0, arg1) { | |
| 506 | const ret = getObject(arg0).push(getObject(arg1)); | |
| 507 | return ret; | |
| 508 | }; | |
| 509 | imports.wbg.__wbg_length_c20a40f15020d68a = function(arg0) { | |
| 510 | const ret = getObject(arg0).length; | |
| 511 | return ret; | |
| 512 | }; | |
| 513 | imports.wbg.__wbg_set_a47bac70306a19a7 = function(arg0, arg1, arg2) { | |
| 514 | getObject(arg0).set(getObject(arg1), arg2 >>> 0); | |
| 515 | }; | |
| 516 | imports.wbg.__wbindgen_throw = function(arg0, arg1) { | |
| 517 | throw new Error(getStringFromWasm0(arg0, arg1)); | |
| 518 | }; | |
| 519 | return imports; | |
| 520 | } | |
| 521 | function __wbg_init_memory(imports, maybe_memory) { | |
| 522 | } | |
| 523 | function __wbg_finalize_init(instance, module) { | |
| 524 | wasm = instance.exports; | |
| 525 | __wbg_init.__wbindgen_wasm_module = module; | |
| 526 | cachedInt32Memory0 = null; | |
| 527 | cachedUint8Memory0 = null; | |
| 528 | return wasm; | |
| 529 | } | |
| 530 | async function __wbg_init(input) { | |
| 531 | if (wasm !== void 0) | |
| 532 | return wasm; | |
| 533 | if (typeof input === "undefined") { | |
| 534 | input = new URL("index_bg.wasm", void 0); | |
| 535 | } | |
| 536 | const imports = __wbg_get_imports(); | |
| 537 | if (typeof input === "string" || typeof Request === "function" && input instanceof Request || typeof URL === "function" && input instanceof URL) { | |
| 538 | input = fetch(input); | |
| 539 | } | |
| 540 | __wbg_init_memory(imports); | |
| 541 | const { instance, module } = await __wbg_load(await input, imports); | |
| 542 | return __wbg_finalize_init(instance, module); | |
| 543 | } | |
| 544 | var dist_default = __wbg_init; | |
| 545 | ||
| 546 | // wasm-binding.ts | |
| 547 | var initialized = false; | |
| 548 | var initWasm = async (module_or_path) => { | |
| 549 | if (initialized) { | |
| 550 | throw new Error("Already initialized. The `initWasm()` function can be used only once."); | |
| 551 | } | |
| 552 | await dist_default(await module_or_path); | |
| 553 | initialized = true; | |
| 554 | }; | |
| 555 | var Resvg2 = class extends Resvg { | |
| 556 | /** | |
| 557 | * @param {Uint8Array | string} svg | |
| 558 | * @param {ResvgRenderOptions | undefined} options | |
| 559 | */ | |
| 560 | constructor(svg, options) { | |
| 561 | if (!initialized) | |
| 562 | throw new Error("Wasm has not been initialized. Call `initWasm()` function."); | |
| 563 | const font = options?.font; | |
| 564 | if (!!font && isCustomFontsOptions(font)) { | |
| 565 | const serializableOptions = { | |
| 566 | ...options, | |
| 567 | font: { | |
| 568 | ...font, | |
| 569 | fontBuffers: void 0 | |
| 570 | } | |
| 571 | }; | |
| 572 | super(svg, JSON.stringify(serializableOptions), font.fontBuffers); | |
| 573 | } else { | |
| 574 | super(svg, JSON.stringify(options)); | |
| 575 | } | |
| 576 | } | |
| 577 | }; | |
| 578 | function isCustomFontsOptions(value) { | |
| 579 | return Object.prototype.hasOwnProperty.call(value, "fontBuffers"); | |
| 580 | } | |
| 581 | export { | |
| 582 | Resvg2 as Resvg, | |
| 583 | initWasm | |
| 584 | }; |
src/web/og/vendor/resvg.wasm added
src/web/pages/og.ts +37 −6
| @@ -7,10 +7,14 @@ | ||
| 7 | 7 | * anonymous reader could open (not sealed, runs readable). Anything else, or a |
| 8 | 8 | * ref that is not shaped like one, is the same 404. The ETag is the sha256 |
| 9 | 9 | * of the card's inputs, so a changed repo is a new card and an unchanged |
| 10 | * one is a 304. | |
| 10 | * one is a 304. The `.png` twin is the same card rasterized once per input | |
| 11 | * hash and kept under the data directory; if the rasterizer is unavailable it | |
| 12 | * answers 503 and nothing else is affected. | |
| 11 | 13 | */ |
| 12 | 14 | |
| 13 | 15 | import { createHash } from "node:crypto"; |
| 16 | import { join } from "node:path"; | |
| 17 | import type { App } from "../../app.ts"; | |
| 14 | 18 | import { getRepoRun } from "../../actions/runs.ts"; |
| 15 | 19 | import { getAppBySlug, isSlug } from "../../core/apps.ts"; |
| 16 | 20 | import { |
| @@ -27,6 +31,8 @@ import { isSafeName } from "../../git/guard.ts"; | ||
| 27 | 31 | import type { Ctx } from "../../http/context.ts"; |
| 28 | 32 | import { notFound } from "../../http/respond.ts"; |
| 29 | 33 | import { renderCard, type CardInput } from "../og/card.ts"; |
| 34 | import { Busy, pngCache, type PngCache } from "../og/png-cache.ts"; | |
| 35 | import { rasterizer } from "../og/raster.ts"; | |
| 30 | 36 | |
| 31 | 37 | /** What an anonymous reader may see of a repository, or nothing. */ |
| 32 | 38 | function publicRepo(ctx: Ctx, repo: Repo | undefined): Repo | undefined { |
| @@ -110,18 +116,43 @@ function resolve(ctx: Ctx, kind: OgKind, ref: string): CardInput | undefined { | ||
| 110 | 116 | return repo && { ...repoInput(ctx, repo, "app"), title: record.slug }; |
| 111 | 117 | } |
| 112 | 118 | |
| 113 | export function ogCard(kind: OgKind) { | |
| 114 | return (ctx: Ctx): Response => { | |
| 119 | const caches = new WeakMap<App, PngCache>(); | |
| 120 | ||
| 121 | function cacheFor(app: App): PngCache { | |
| 122 | let c = caches.get(app); | |
| 123 | if (!c) { | |
| 124 | c = pngCache({ | |
| 125 | dir: join(app.config.dataDir, "og-png"), | |
| 126 | raster: rasterizer, | |
| 127 | svg: renderCard, | |
| 128 | }); | |
| 129 | caches.set(app, c); | |
| 130 | } | |
| 131 | return c; | |
| 132 | } | |
| 133 | ||
| 134 | export function ogCard(kind: OgKind, format: "svg" | "png" = "svg") { | |
| 135 | return async (ctx: Ctx): Promise<Response> => { | |
| 115 | 136 | const input = resolve(ctx, kind, ctx.params.ref ?? ""); |
| 116 | 137 | if (!input) return notFound(); |
| 117 | const etag = `"${createHash("sha256").update(JSON.stringify(input)).digest("hex")}"`; | |
| 138 | const etag = `"${createHash("sha256").update(JSON.stringify(input)).digest("hex")}${format === "png" ? "-png" : ""}"`; | |
| 118 | 139 | const headers = { |
| 119 | 140 | etag, |
| 120 | 141 | "cache-control": "public, max-age=300", |
| 121 | "content-type": "image/svg+xml; charset=utf-8", | |
| 142 | "content-type": format === "png" ? "image/png" : "image/svg+xml; charset=utf-8", | |
| 122 | 143 | }; |
| 123 | 144 | if (ctx.request.headers.get("if-none-match") === etag) |
| 124 | 145 | return new Response(null, { status: 304, headers }); |
| 125 | return new Response(renderCard(input), { headers }); | |
| 146 | if (format === "svg") return new Response(renderCard(input), { headers }); | |
| 147 | try { | |
| 148 | return new Response(await cacheFor(ctx.app).get(input), { headers }); | |
| 149 | } catch (error) { | |
| 150 | if (!(error instanceof Busy)) | |
outdated
Not blocking. Under a crawl, every refused request (queue full) logs at error level, which is a flood of red lines for something working as designed. Consider logging only real rasterizer failures at error level and counting queue refusals quietly, or logging them once per window. |
||
| 151 | ctx.app.log.error("og card could not be rasterized", { kind, error: String(error) }); | |
| 152 | return new Response("card unavailable\n", { | |
| 153 | status: 503, | |
| 154 | headers: { "content-type": "text/plain; charset=utf-8", "retry-after": "30" }, | |
| 155 | }); | |
| 156 | } | |
| 126 | 157 | }; |
| 127 | 158 | } |
test/og-png.test.ts added +173
| @@ -0,0 +1,173 @@ | ||
| 1 | import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, readdir, rm, stat } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { createApp as createForge, type App } from "../src/app.ts"; | |
| 6 | import { createRepo } from "../src/actions/repos.ts"; | |
| 7 | import { createUser } from "../src/core/users.ts"; | |
| 8 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 9 | import { renderCard } from "../src/web/og/card.ts"; | |
| 10 | import { cardKey, pngCache } from "../src/web/og/png-cache.ts"; | |
| 11 | import { useLoader } from "../src/web/og/raster.ts"; | |
| 12 | ||
| 13 | const PNG = [0x89, 0x50, 0x4e, 0x47]; | |
| 14 | ||
| 15 | describe("og png", () => { | |
| 16 | let dataDir: string; | |
| 17 | let app: App; | |
| 18 | let servers: Servers; | |
| 19 | let base: string; | |
| 20 | ||
| 21 | beforeAll(async () => { | |
| 22 | dataDir = await mkdtemp(join(tmpdir(), "forge-ogpng-")); | |
| 23 | app = createForge({ dataDir, port: 0 }); | |
| 24 | servers = await serve(app); | |
| 25 | base = servers.url; | |
| 26 | const u = await createUser(app.db, { | |
| 27 | trusted: true, | |
| 28 | username: "alice", | |
| 29 | email: "[email protected]", | |
| 30 | password: "alice-passphrase", | |
| 31 | adminIfFirst: true, | |
| 32 | }); | |
| 33 | if (!u.ok) throw new Error(u.error); | |
| 34 | const made = await createRepo(app, u.value, { name: "shown", visibility: "public" }); | |
| 35 | if (!made.ok) throw new Error(String(made.error)); | |
| 36 | await createRepo(app, u.value, { name: "hidden", visibility: "private" }); | |
| 37 | }); | |
| 38 | ||
| 39 | afterEach(() => useLoader()); | |
| 40 | ||
| 41 | afterAll(async () => { | |
| 42 | await servers.stop(); | |
| 43 | await rm(dataDir, { recursive: true, force: true }); | |
| 44 | }); | |
| 45 | ||
| 46 | test("a PNG of the card, rasterized once and served from disk after", async () => { | |
| 47 | const cold = await fetch(`${base}/og/repo/alice~shown.png`); | |
| 48 | expect(cold.status).toBe(200); | |
| 49 | expect(cold.headers.get("content-type")).toBe("image/png"); | |
| 50 | expect(cold.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 51 | const bytes = new Uint8Array(await cold.arrayBuffer()); | |
| 52 | expect([...bytes.slice(0, 4)]).toEqual(PNG); | |
| 53 | // 1200×630 in the IHDR. | |
| 54 | const dv = new DataView(bytes.buffer); | |
| 55 | expect([dv.getUint32(16), dv.getUint32(20)]).toEqual([1200, 630]); | |
| 56 | const files = await readdir(join(dataDir, "og-png")); | |
| 57 | expect(files).toHaveLength(1); | |
| 58 | const written = (await stat(join(dataDir, "og-png", files[0]!))).ino; | |
| 59 | ||
| 60 | const warm = new Uint8Array(await (await fetch(`${base}/og/repo/alice~shown.png`)).arrayBuffer()); | |
| 61 | expect(warm).toEqual(bytes); | |
| 62 | expect(await readdir(join(dataDir, "og-png"))).toEqual(files); | |
| 63 | expect((await stat(join(dataDir, "og-png", files[0]!))).ino).toBe(written); | |
| 64 | ||
| 65 | const etag = cold.headers.get("etag")!; | |
| 66 | const again = await fetch(`${base}/og/repo/alice~shown.png`, { headers: { "if-none-match": etag } }); | |
| 67 | expect(again.status).toBe(304); | |
| 68 | }); | |
| 69 | ||
| 70 | test("private and malformed refs are the same 404", async () => { | |
| 71 | expect((await fetch(`${base}/og/repo/alice~hidden.png`)).status).toBe(404); | |
| 72 | expect((await fetch(`${base}/og/repo/..%2F..~x.png`)).status).toBe(404); | |
| 73 | }); | |
| 74 | ||
| 75 | test("a rasterizer that fails to load answers 503 and the forge still answers", async () => { | |
| 76 | useLoader(() => Promise.reject(new Error("no wasm here"))); | |
| 77 | const res = await fetch(`${base}/og/repo/alice~shown.png`); | |
| 78 | // Cached from the first test: a warm card needs no rasterizer. | |
| 79 | expect(res.status).toBe(200); | |
| 80 | await rm(join(dataDir, "og-png"), { recursive: true, force: true }); | |
| 81 | // A fresh forge-side cache is per app; drop the on-disk card and ask again. | |
| 82 | const fresh = await fetch(`${base}/og/repo/alice~shown.png`); | |
| 83 | expect(fresh.status).toBe(503); | |
| 84 | expect((await fresh.text()).length).toBeLessThan(100); | |
| 85 | expect((await fetch(`${base}/og/repo/alice~shown.svg`)).status).toBe(200); | |
| 86 | expect((await fetch(`${base}/`)).status).toBeLessThan(500); | |
| 87 | }); | |
| 88 | ||
| 89 | test("the cache keeps at most its cap, least recently used out", async () => { | |
| 90 | const dir = await mkdtemp(join(tmpdir(), "forge-ogcap-")); | |
| 91 | try { | |
| 92 | let renders = 0; | |
| 93 | const cache = pngCache({ | |
| 94 | dir, | |
| 95 | cap: 2, | |
| 96 | svg: renderCard, | |
| 97 | raster: async () => () => (renders++, new Uint8Array([...PNG, renders])), | |
| 98 | }); | |
| 99 | const card = (n: number) => ({ kind: "page" as const, title: `card ${n}` }); | |
| 100 | await cache.get(card(1)); | |
| 101 | await cache.get(card(2)); | |
| 102 | await cache.get(card(1)); // 1 is now the freshest | |
| 103 | await cache.get(card(3)); // evicts 2 | |
| 104 | const files = (await readdir(dir)).sort(); | |
| 105 | expect(files).toEqual([cardKey(card(1)) + ".png", cardKey(card(3)) + ".png"].sort()); | |
| 106 | expect(renders).toBe(3); | |
| 107 | await cache.get(card(1)); | |
| 108 | expect(renders).toBe(3); | |
| 109 | } finally { | |
| 110 | await rm(dir, { recursive: true, force: true }); | |
| 111 | } | |
| 112 | }); | |
| 113 | ||
| 114 | test("a render that throws answers 503, leaves no file, and a good rasterizer then serves", async () => { | |
outdated
This test doesn't touch the cache or the route; it only checks the stub loader rejects. Replace it with a test where the loader resolves but the render throws. Assert 503 + retry-after, no file (including |
||
| 115 | await rm(join(dataDir, "og-png"), { recursive: true, force: true }); | |
| 116 | useLoader(async () => () => { | |
| 117 | throw new Error("render blew up"); | |
| 118 | }); | |
| 119 | const bad = await fetch(`${base}/og/repo/alice~shown.png`); | |
| 120 | expect(bad.status).toBe(503); | |
| 121 | expect(bad.headers.get("retry-after")).toBeTruthy(); | |
| 122 | expect(await readdir(join(dataDir, "og-png")).catch(() => [])).toEqual([]); | |
| 123 | ||
| 124 | // The directory vanishing at runtime is survivable too. | |
| 125 | await rm(join(dataDir, "og-png"), { recursive: true, force: true }); | |
| 126 | useLoader(); | |
| 127 | const good = await fetch(`${base}/og/repo/alice~shown.png`); | |
| 128 | expect(good.status).toBe(200); | |
| 129 | expect([...new Uint8Array(await good.arrayBuffer()).slice(0, 4)]).toEqual(PNG); | |
| 130 | expect(await readdir(join(dataDir, "og-png"))).toHaveLength(1); | |
| 131 | }); | |
| 132 | ||
| 133 | test("cold renders run one at a time and a long queue is refused", async () => { | |
| 134 | const dir = await mkdtemp(join(tmpdir(), "forge-ogq-")); | |
| 135 | try { | |
| 136 | let running = 0; | |
| 137 | let peak = 0; | |
| 138 | const cache = pngCache({ | |
| 139 | dir, | |
| 140 | queue: 3, | |
| 141 | svg: renderCard, | |
| 142 | raster: async () => () => { | |
| 143 | peak = Math.max(peak, ++running); | |
| 144 | running--; | |
| 145 | return new Uint8Array(PNG); | |
| 146 | }, | |
| 147 | }); | |
| 148 | const card = (n: number) => ({ kind: "page" as const, title: `card ${n}` }); | |
| 149 | const results = await Promise.allSettled([1, 2, 3, 4, 5].map((n) => cache.get(card(n)))); | |
| 150 | expect(results.map((r) => r.status)).toEqual(["fulfilled", "fulfilled", "fulfilled", "rejected", "rejected"]); | |
| 151 | expect(peak).toBe(1); | |
| 152 | // Once drained, a refused card can be made. | |
| 153 | await cache.get(card(4)); | |
| 154 | } finally { | |
| 155 | await rm(dir, { recursive: true, force: true }); | |
| 156 | } | |
| 157 | }); | |
| 158 | ||
| 159 | test("a directory already over the cap is trimmed on first use", async () => { | |
| 160 | const dir = await mkdtemp(join(tmpdir(), "forge-ogtrim-")); | |
| 161 | try { | |
| 162 | const mk = (cap: number) => | |
| 163 | pngCache({ dir, cap, svg: renderCard, raster: async () => () => new Uint8Array(PNG) }); | |
| 164 | const card = (n: number) => ({ kind: "page" as const, title: `card ${n}` }); | |
| 165 | const big = mk(10); | |
| 166 | for (const n of [1, 2, 3, 4]) await big.get(card(n)); | |
| 167 | await mk(2).get(card(1)); | |
| 168 | expect(await readdir(dir)).toHaveLength(2); | |
| 169 | } finally { | |
| 170 | await rm(dir, { recursive: true, force: true }); | |
| 171 | } | |
| 172 | }); | |
| 173 | }); |
This
mkdirruns once per process becausescannedis memoized. If og-png is removed at runtime, every laterwriteFileinmakefails with ENOENT and the route 503s until restart. Runmkdir(opts.dir, { recursive: true })inmakebefore the write.