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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/1803-og-cards-as-png-rasterized-once-per-inpu → main 5 commits fork 6 threads · 6 unresolved

  • Merged. scout/1803-og-cards-as-png-rasterized-once-per-inpu was merged into main as b9ac835.

Review

Sign in to review this change.
15 files changed +1312 −6
README.md +27
@@ -994,3 +994,30 @@ links to the deck, whatever the number of siblings; it is never muted away.
994994 Once a sibling ships or all but one close, the item leaves the feed. The
995995 terminal has no feed; the item is in `GET /api/v1/feed` and the MCP feed tool
996996 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[] = [
6767 "test/claim-link.test.ts",
6868 "test/og-card.test.ts",
6969 "test/og-route.test.ts",
70 "test/og-png.test.ts",
7071 "test/router.test.ts",
7172 "test/health.test.ts",
7273 "test/client-address.test.ts",
src/http/server.ts +5
@@ -1061,6 +1061,11 @@ export function buildRouter(): Router<Handler> {
10611061 router.add("GET", "/og/issue/:ref.svg", ogCard("issue"));
10621062 router.add("GET", "/og/pull/:ref.svg", ogCard("pull"));
10631063 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"));
10641069 // The pictures of an environment, read like the repository.
10651070 router.add("GET", "/:owner/:name/looks/:env/:commit/:kind.png", lookImage);
10661071
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;
outdated

This mkdir runs once per process because scanned is memoized. If og-png is removed at runtime, every later writeFile in make fails with ENOENT and the route 503s until restart. Run mkdir(opts.dir, { recursive: true }) in make before the write.

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. pending only dedupes the same key. Serialize cold renders (one at a time), and when the queue is longer than a small bound, answer 503 + retry-after, so a crawl over many public runs can't hold the event loop.

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 OG_PNG_CAP (512). So a crawler with 4 requests in flight across distinct refs can keep the event loop near 100% busy and add ~100 ms to every other request for as long as it crawls. When this gets a follow-up, move the render into a Worker so the loop never waits on resvg.

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 trim() can unlink this file between exists() and arrayBuffer(). The read then throws, and the route logs that as a rasterizer error with a 503. That's harmless, but falling through to a render on a read failure would be cleaner.

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
Binary file not shown.
src/web/og/vendor/DejaVuSans.ttf added
Binary file not shown.
src/web/og/vendor/DejaVuSansMono-Bold.ttf added
Binary file not shown.
src/web/og/vendor/DejaVuSansMono.ttf added
Binary file not shown.
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
Binary file not shown.
src/web/pages/og.ts +37 −6
@@ -7,10 +7,14 @@
77 * anonymous reader could open (not sealed, runs readable). Anything else, or a
88 * ref that is not shaped like one, is the same 404. The ETag is the sha256
99 * 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.
1113 */
1214
1315 import { createHash } from "node:crypto";
16 import { join } from "node:path";
17 import type { App } from "../../app.ts";
1418 import { getRepoRun } from "../../actions/runs.ts";
1519 import { getAppBySlug, isSlug } from "../../core/apps.ts";
1620 import {
@@ -27,6 +31,8 @@ import { isSafeName } from "../../git/guard.ts";
2731 import type { Ctx } from "../../http/context.ts";
2832 import { notFound } from "../../http/respond.ts";
2933 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";
3036
3137 /** What an anonymous reader may see of a repository, or nothing. */
3238 function publicRepo(ctx: Ctx, repo: Repo | undefined): Repo | undefined {
@@ -110,18 +116,43 @@ function resolve(ctx: Ctx, kind: OgKind, ref: string): CardInput | undefined {
110116 return repo && { ...repoInput(ctx, repo, "app"), title: record.slug };
111117 }
112118
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> => {
115136 const input = resolve(ctx, kind, ctx.params.ref ?? "");
116137 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" : ""}"`;
118139 const headers = {
119140 etag,
120141 "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",
122143 };
123144 if (ctx.request.headers.get("if-none-match") === etag)
124145 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 }
126157 };
127158 }
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 .tmp) left in og-png, and that a good rasterizer then serves the card.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs