perf(web): generate avatar thumbnails automatically #122

Merged
eleith merged 1 commits from perf/right-size-avatar into main 2026-11-03 14:34:53 +00:00
15 changed files with 1077 additions and 59 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
'@when/web': patch
---
Upgrade the Takumi image renderer to 2.19.0.
+7
View File
@@ -0,0 +1,7 @@
---
'@when/web': patch
---
Serve the configured avatar through `/avatar/<configured-path>`, with `?thumbnail=true` providing a cached, automatically resized web avatar without a separate image or new configuration. The requested path is only validated against configuration; it never selects another source. Keep original images for share cards, preserve SVGs and animations, and use the original when resizing cannot reduce the download. Document Takumi's existing EXIF-orientation limitation.
Share concurrent source loading and resizing, and forward originals without mismatched compression headers.
+156
View File
@@ -0,0 +1,156 @@
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { parse, stringify } from 'yaml';
import { Renderer } from 'takumi-js/node';
import { avatarThumbnailUrl } from '../src/lib/avatar.ts';
import { expect, test, type Page } from './support/test.ts';
import { workerDirectory } from './support/paths.ts';
import { seedAppointment } from './support/seed.ts';
const SOURCE_PATH = '/public/avatar source test.png';
const ORIGINAL_URL = '/avatar/public/avatar%20source%20test.png';
const THUMBNAIL_URL = `${ORIGINAL_URL}?thumbnail=true`;
const DEFAULT_AVATAR_URL = '/avatar/assets/images/avatar.svg?thumbnail=true';
test.use({ javaScriptEnabled: false });
async function setAvatarSource(page: Page, sourcePath: string) {
const configPath = join(workerDirectory(), 'config/when.yaml');
const config = parse(await readFile(configPath, 'utf8'));
config.user.appearance = { ...config.user.appearance, avatar_path: sourcePath };
await writeFile(configPath, stringify(config));
const expectedUrl = avatarThumbnailUrl(sourcePath).replaceAll('&', '&amp;');
await expect.poll(async () => (await page.request.get('/')).text()).toContain(expectedUrl);
}
async function withRasterAvatar(page: Page, run: (source: Buffer) => Promise<void>) {
const directory = workerDirectory();
const configPath = join(directory, 'config/when.yaml');
const originalConfig = await readFile(configPath, 'utf8');
const sourceFile = join(directory, 'public/avatar source test.png');
const source = await new Renderer().render(
{ type: 'container', style: { width: 800, height: 800, backgroundColor: '#166534' } },
{ width: 800, height: 800, format: 'png' }
);
await mkdir(join(directory, 'public'), { recursive: true });
try {
await writeFile(sourceFile, source);
await setAvatarSource(page, SOURCE_PATH);
await run(source);
} finally {
await writeFile(configPath, originalConfig);
await expect
.poll(async () => (await page.request.get('/')).text())
.toContain(DEFAULT_AVATAR_URL);
await rm(sourceFile, { force: true });
}
}
async function expectPageAvatars(page: Page, count: number) {
const avatars = page.getByRole('img', { name: 'E2E Fixture', exact: true, includeHidden: true });
await expect(avatars).toHaveCount(count);
for (const avatar of await avatars.all()) {
await expect(avatar).toHaveAttribute('src', THUMBNAIL_URL);
}
await expect
.poll(() =>
avatars.evaluateAll((images) =>
images.every((image) => {
const img = image as HTMLImageElement;
return img.complete && img.naturalWidth === 216 && img.naturalHeight === 216;
})
)
)
.toBe(true);
}
test('default SVG avatars stay vector images', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('img', { name: 'E2E Fixture', exact: true })).toHaveAttribute(
'src',
DEFAULT_AVATAR_URL
);
const response = await page.request.get(DEFAULT_AVATAR_URL);
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toBe('image/svg+xml');
});
test('original and thumbnail variants preserve their bytes and cache validators', async ({
page
}) => {
await withRasterAvatar(page, async (source) => {
const thumbnail = await page.request.get(THUMBNAIL_URL);
expect(thumbnail.status()).toBe(200);
expect(thumbnail.headers()['content-type']).toBe('image/webp');
expect((await thumbnail.body()).byteLength).toBeLessThan(source.byteLength);
const original = await page.request.get(ORIGINAL_URL);
expect(original.headers()['content-type']).toBe('image/png');
expect(await original.body()).toEqual(source);
const explicitOriginal = await page.request.get(`${ORIGINAL_URL}?thumbnail=false`);
expect(await explicitOriginal.body()).toEqual(source);
for (const [url, response] of [
[ORIGINAL_URL, original],
[THUMBNAIL_URL, thumbnail]
] as const) {
const notModified = await page.request.get(url, {
headers: { 'if-none-match': response.headers()['etag'] }
});
expect(notModified.status()).toBe(304);
}
});
});
test('pages download thumbnails without JavaScript on mobile and desktop', async ({ page }) => {
await withRasterAvatar(page, async () => {
const imageRequests: string[] = [];
page.on('request', (request) => {
if (request.resourceType() === 'image') imageRequests.push(new URL(request.url()).pathname);
});
const appointment = await seedAppointment();
const pages = [
{ path: '/', avatars: 1 },
{ path: '/schedule/chat', avatars: 2 },
{
path: `/appointment/${appointment.id}/reschedule?token=${appointment.cancel_token}`,
avatars: 2
}
];
for (const width of [390, 1280]) {
await page.setViewportSize({ width, height: 900 });
for (const scenario of pages) {
await page.goto(scenario.path);
await expectPageAvatars(page, scenario.avatars);
}
}
expect(imageRequests).toContain(ORIGINAL_URL);
expect(imageRequests).not.toContain('/public/avatar%20source%20test.png');
});
});
test('avatar paths cannot select another public image or recurse into this route', async ({
page
}) => {
await withRasterAvatar(page, async (source) => {
const otherFile = join(workerDirectory(), 'public/not-configured.png');
try {
await writeFile(otherFile, source);
expect((await page.request.get('/public/not-configured.png')).status()).toBe(200);
for (const search of ['', '?thumbnail=true']) {
expect(
(await page.request.get(`/avatar/public/not-configured.png${search}`)).status()
).toBe(404);
}
expect((await page.request.get('/avatar')).status()).toBe(404);
await setAvatarSource(page, '/%61vatar');
for (const search of ['', '?thumbnail=true']) {
const rejected = await page.request.get(`/avatar/%61vatar${search}`, { timeout: 5_000 });
expect(rejected.status()).toBe(404);
}
} finally {
await rm(otherFile, { force: true });
}
});
});
+1 -1
View File
@@ -65,7 +65,7 @@
"mrmime": "2.0.1",
"openworkflow": "0.11.0",
"pino": "10.3.1",
"takumi-js": "2.2.0",
"takumi-js": "2.19.0",
"temporal-polyfill": "1.0.1",
"ts-ics": "2.4.4",
"yaml": "2.9.0"
+27
View File
@@ -0,0 +1,27 @@
import { expect, test } from 'vitest';
import { avatarThumbnailUrl } from './avatar';
test('uses the configured path as the thumbnail URL path, not a query parameter', () => {
expect(avatarThumbnailUrl('/public/photo.webp')).toBe('/avatar/public/photo.webp?thumbnail=true');
});
test('preserves source query parameters and SVG fragments', () => {
expect(avatarThumbnailUrl('/public/photo.svg?revision=2#face')).toBe(
'/avatar/public/photo.svg?revision=2&thumbnail=true#face'
);
});
test('encodes filenames without encoding their path separators', () => {
expect(avatarThumbnailUrl('/public/profile photo.webp')).toBe(
'/avatar/public/profile%20photo.webp?thumbnail=true'
);
expect(avatarThumbnailUrl('/public/profile%20photo.webp')).toBe(
'/avatar/public/profile%20photo.webp?thumbnail=true'
);
});
test('the thumbnail selector cannot be overridden by the configured source query', () => {
expect(avatarThumbnailUrl('/public/photo.webp?thumbnail=false')).toBe(
'/avatar/public/photo.webp?thumbnail=true'
);
});
+7
View File
@@ -0,0 +1,7 @@
/** Put the configured path in the URL for cache identity, not source selection. */
export function avatarThumbnailUrl(avatarPath: string): string {
const url = new URL(avatarPath, 'http://when.invalid');
url.pathname = `/avatar${url.pathname}`;
url.searchParams.set('thumbnail', 'true');
return `${url.pathname}${url.search}${url.hash}`;
}
@@ -1,5 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import { avatarThumbnailUrl } from '#lib/avatar.js';
import DatePicker from '#lib/components/DatePicker.svelte';
import DayTimeline from '#lib/components/DayTimeline.svelte';
import WizardPickerControls from '#lib/components/WizardPickerControls.svelte';
@@ -128,7 +129,11 @@
<header class="page-banner">
<a href="/" class="banner-link">
{#if data.user.appearance.avatar_path}
<img src={data.user.appearance.avatar_path} alt={data.user.name} class="banner-avatar" />
<img
src={avatarThumbnailUrl(data.user.appearance.avatar_path)}
alt={data.user.name}
class="banner-avatar"
/>
{/if}
<div class="banner-text">
<span class="banner-title">{data.user.appearance.title}</span>
@@ -1,6 +1,7 @@
<!-- Desktop-only sidebar: provider and event summary. Hidden on mobile. -->
<script lang="ts">
import type { Appearance } from '@when/config';
import { avatarThumbnailUrl } from '#lib/avatar.js';
interface Props {
appearance: Appearance;
@@ -16,7 +17,7 @@
<section class="section">
<a href="/" class="provider">
{#if appearance.avatar_path}
<img src={appearance.avatar_path} alt={providerName} class="avatar" />
<img src={avatarThumbnailUrl(appearance.avatar_path)} alt={providerName} class="avatar" />
{/if}
</a>
</section>
+247 -1
View File
@@ -1,4 +1,5 @@
import { expect, test } from 'vitest';
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import { Renderer } from 'takumi-js/node';
import { defaultAvatar } from './avatar';
test('renders a deterministic svg from a seed', () => {
@@ -15,3 +16,248 @@ test('applies brand background and text colors', () => {
expect(svg).toContain('#166534');
expect(svg).toContain('#ededed');
});
describe('thumbnails', () => {
const fixtureRenderer = new Renderer();
let sources: Record<string, Buffer>;
let getAvatarThumbnail: typeof import('./avatar').getAvatarThumbnail;
beforeAll(async () => {
const node = {
type: 'container' as const,
style: {
width: 800,
height: 800,
backgroundImage: 'linear-gradient(45deg, #166534, #34d399, #2563eb)'
}
};
sources = {};
for (const format of ['png', 'jpeg', 'webp'] as const) {
sources[`image/${format}`] = await fixtureRenderer.render(node, {
width: 800,
height: 800,
format
});
}
});
beforeEach(async () => {
vi.resetModules();
({ getAvatarThumbnail } = await import('./avatar'));
});
function sourceFetch(bytes: Uint8Array, contentType: string, etag?: string) {
return vi.fn<typeof fetch>().mockImplementation(async () => {
const headers = new Headers({ 'content-type': contentType });
if (etag) headers.set('etag', etag);
return new Response(Uint8Array.from(bytes), { headers });
});
}
async function dimensions(bytes: Uint8Array) {
const png = await fixtureRenderer.render({ type: 'image', src: bytes }, { format: 'png' });
return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
}
test.each(['image/png', 'image/jpeg', 'image/webp'])(
'resizes %s to a smaller 216px WebP',
async (type) => {
const source = sources[type];
const image = await getAvatarThumbnail(sourceFetch(source, type), '/public/avatar');
expect(image.contentType).toBe('image/webp');
expect(image.bytes.byteLength).toBeLessThan(source.byteLength);
expect(await dimensions(image.bytes)).toEqual({ width: 216, height: 216 });
}
);
test('infers MIME from the configured filename when dev fetch omits its headers', async () => {
const fetchSource = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response(Uint8Array.from(sources['image/webp'])));
const image = await getAvatarThumbnail(fetchSource, '/public/avatar.webp?revision=2');
expect(image.contentType).toBe('image/webp');
expect(image.bytes.byteLength).toBeLessThan(sources['image/webp'].byteLength);
expect(await dimensions(image.bytes)).toEqual({ width: 216, height: 216 });
});
test('does not override an explicit non-image source type based on its filename', async () => {
const fetchSource = sourceFetch(Buffer.from('not an image'), 'text/html');
await expect(getAvatarThumbnail(fetchSource, '/public/avatar.png')).rejects.toThrow(
'not an image'
);
});
test('SVG avatars remain vectors with unchanged bytes', async () => {
const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800"/>');
const image = await getAvatarThumbnail(sourceFetch(svg, 'image/svg+xml'), '/public/avatar.svg');
expect(image.contentType).toBe('image/svg+xml');
expect(Buffer.from(image.bytes)).toEqual(svg);
});
test('does not enlarge a small image', async () => {
const bytes = await fixtureRenderer.render(
{ type: 'container', style: { width: 40, height: 40, backgroundColor: 'green' } },
{ width: 40, height: 40, format: 'png' }
);
const image = await getAvatarThumbnail(sourceFetch(bytes, 'image/png'), '/public/avatar.png');
expect(image.contentType).toBe('image/png');
expect(Buffer.from(image.bytes)).toEqual(bytes);
});
test('retains a large source if encoding it would increase its bytes', async () => {
// A flat 800px lossless WebP is already smaller than the lossy thumbnail.
const source = await fixtureRenderer.render(
{ type: 'container', style: { width: 800, height: 800, backgroundColor: 'transparent' } },
{ width: 800, height: 800, format: 'webp', lossless: true }
);
const image = await getAvatarThumbnail(
sourceFetch(source, 'image/webp'),
'/public/avatar.webp'
);
expect(Buffer.from(image.bytes)).toEqual(source);
});
test.each(['image/gif', 'image/avif', 'image/png'])(
'serves original %s bytes if not decodable',
async (type) => {
const bytes = Buffer.from('not a decodable image');
const image = await getAvatarThumbnail(sourceFetch(bytes, type), '/public/avatar');
expect(image.contentType).toBe(type);
expect(Buffer.from(image.bytes)).toEqual(bytes);
}
);
test('reuses the derivative when source validation returns 304', async () => {
const fetchSource = sourceFetch(sources['image/png'], 'image/png', '"source-v1"');
const first = await getAvatarThumbnail(fetchSource, '/public/avatar.png');
fetchSource.mockResolvedValueOnce(new Response(null, { status: 304 }));
const second = await getAvatarThumbnail(fetchSource, '/public/avatar.png');
expect(second).toBe(first);
const options = fetchSource.mock.calls[1][1];
expect(new Headers(options?.headers).get('if-none-match')).toBe('"source-v1"');
expect(new Headers(options?.headers).get('accept-encoding')).toBe('identity');
expect(options?.redirect).toBe('error');
});
test('concurrent requests share the source fetch and resize', async () => {
const fetchSource = sourceFetch(sources['image/png'], 'image/png');
const [first, second] = await Promise.all([
getAvatarThumbnail(fetchSource, '/public/avatar.png'),
getAvatarThumbnail(fetchSource, '/public/avatar.png')
]);
expect(second).toBe(first);
expect(fetchSource).toHaveBeenCalledOnce();
});
test('revalidates between requests but reuses unchanged bytes without validators', async () => {
const fetchSource = sourceFetch(sources['image/png'], 'image/png');
const first = await getAvatarThumbnail(fetchSource, '/public/avatar.png');
const second = await getAvatarThumbnail(fetchSource, '/public/avatar.png');
expect(second).toBe(first);
expect(fetchSource).toHaveBeenCalledTimes(2);
});
test('concurrent source failures are shared and a later request can retry', async () => {
const fetchSource = sourceFetch(sources['image/png'], 'image/png');
fetchSource.mockResolvedValueOnce(new Response(null, { status: 404 }));
const attempts = await Promise.allSettled([
getAvatarThumbnail(fetchSource, '/public/avatar.png'),
getAvatarThumbnail(fetchSource, '/public/avatar.png')
]);
expect(attempts.map((attempt) => attempt.status)).toEqual(['rejected', 'rejected']);
expect(fetchSource).toHaveBeenCalledOnce();
await expect(getAvatarThumbnail(fetchSource, '/public/avatar.png')).resolves.toHaveProperty(
'bytes'
);
expect(fetchSource).toHaveBeenCalledTimes(2);
});
test('refuses ambiguous compressed bytes when a source ignores identity encoding', async () => {
const fetchSource = sourceFetch(sources['image/png'], 'image/png');
fetchSource.mockResolvedValueOnce(
new Response(Uint8Array.from(sources['image/png']), {
headers: { 'content-type': 'image/png', 'content-encoding': 'gzip' }
})
);
await expect(getAvatarThumbnail(fetchSource, '/public/avatar.png')).rejects.toThrow(
'identity encoding'
);
});
test('replaces the cached derivative when the source changes at the same path', async () => {
const fetchSource = sourceFetch(sources['image/png'], 'image/png', '"source-v1"');
const first = await getAvatarThumbnail(fetchSource, '/public/avatar.png');
const changed = await fixtureRenderer.render(
{ type: 'container', style: { width: 800, height: 800, backgroundColor: 'blue' } },
{ width: 800, height: 800, format: 'png' }
);
fetchSource.mockImplementation(sourceFetch(changed, 'image/png', '"source-v2"'));
const second = await getAvatarThumbnail(fetchSource, '/public/avatar.png');
expect(second.etag).not.toBe(first.etag);
});
test('a changed configured path is fetched without the previous source validator', async () => {
const fetchSource = sourceFetch(sources['image/png'], 'image/png', '"source-v1"');
await getAvatarThumbnail(fetchSource, '/public/first.png');
await getAvatarThumbnail(fetchSource, '/public/second.png');
expect(fetchSource.mock.calls[1][0]).toBe('/public/second.png');
expect(new Headers(fetchSource.mock.calls[1][1]?.headers).has('if-none-match')).toBe(false);
});
test('a failed source request can be retried', async () => {
const fetchSource = sourceFetch(sources['image/png'], 'image/png');
fetchSource.mockResolvedValueOnce(new Response(null, { status: 404 }));
await expect(getAvatarThumbnail(fetchSource, '/public/avatar.png')).rejects.toThrow('HTTP 404');
await expect(getAvatarThumbnail(fetchSource, '/public/avatar.png')).resolves.toHaveProperty(
'bytes'
);
});
test('rejects oversized sources with or without Content-Length', async () => {
const fetchSource = vi.fn<typeof fetch>();
fetchSource.mockResolvedValueOnce(
new Response(null, { headers: { 'content-type': 'image/png', 'content-length': '9000000' } })
);
await expect(getAvatarThumbnail(fetchSource, '/public/avatar.png')).rejects.toThrow(
'source limit'
);
fetchSource.mockResolvedValueOnce(
new Response(new Uint8Array(8 * 1024 * 1024 + 1), {
headers: { 'content-type': 'image/png' }
})
);
await expect(getAvatarThumbnail(fetchSource, '/public/avatar.png')).rejects.toThrow(
'source limit'
);
});
test.each([
['webp', 'image/webp'],
['apng', 'image/png']
] as const)(
'preserves a valid animated %s instead of flattening it',
async (format, contentType) => {
const bytes = await fixtureRenderer.renderAnimation({
width: 512,
height: 512,
fps: 10,
format,
scenes: ['red', 'blue'].map((color) => ({
node: {
type: 'container' as const,
style: {
width: 512,
height: 512,
backgroundImage: `linear-gradient(45deg, ${color}, green)`
}
},
durationMs: 100
}))
});
expect(await dimensions(bytes)).toEqual({ width: 512, height: 512 });
const image = await getAvatarThumbnail(sourceFetch(bytes, contentType), '/public/animated');
expect(image.contentType).toBe(contentType);
expect(Buffer.from(image.bytes)).toEqual(bytes);
}
);
});
+165
View File
@@ -1,4 +1,7 @@
import { createHash } from 'node:crypto';
import { Avatar, Style } from '@dicebear/core';
import { Renderer } from 'takumi-js/node';
import { lookup } from 'mrmime';
// DiceBear "thumbs" style, vendored (CC0 1.0, see meta.license in the JSON).
import thumbs from './thumbs.json';
@@ -29,3 +32,165 @@ export function defaultAvatar(seed: string, colors: AvatarColors = {}): string {
})
}).toString();
}
const SIZE = 216;
const MAX_SOURCE_BYTES = 8 * 1024 * 1024;
export interface AvatarThumbnail {
bytes: Uint8Array<ArrayBuffer>;
contentType: string;
etag: string;
}
let renderer: Renderer | undefined;
let cachedAvatar: {
source: string;
contentType: string;
sourceDigest: string;
sourceEtag: string | null;
thumbnail: AvatarThumbnail;
} | null = null;
const inFlightAvatars = new Map<string, Promise<AvatarThumbnail>>();
function digest(bytes: Uint8Array): string {
return createHash('sha256').update(bytes).digest('hex');
}
async function readSource(response: Response): Promise<Buffer> {
if (Number(response.headers.get('content-length')) > MAX_SOURCE_BYTES) {
await response.body?.cancel();
throw new Error('Avatar exceeds the thumbnail source limit.');
}
const reader = response.body?.getReader();
if (!reader) throw new Error('Avatar has no image body.');
const chunks: Uint8Array[] = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > MAX_SOURCE_BYTES) {
await reader.cancel();
throw new Error('Avatar exceeds the thumbnail source limit.');
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
return Buffer.concat(chunks);
}
function isAnimated(bytes: Buffer, contentType: string): boolean {
if (contentType === 'image/webp') {
return bytes.length > 20 && bytes.toString('ascii', 12, 16) === 'VP8X' && !!(bytes[20] & 2);
}
if (contentType === 'image/png') {
// APNG declares its animation before the first image-data chunk.
for (let offset = 8; offset + 12 <= bytes.length; ) {
const type = bytes.toString('ascii', offset + 4, offset + 8);
if (type === 'acTL') return true;
if (type === 'IDAT') return false;
offset += 12 + bytes.readUInt32BE(offset);
}
}
return false;
}
/** Return a smaller WebP, or null when the original should be kept. */
async function tryResizeAvatar(bytes: Buffer, contentType: string): Promise<Uint8Array | null> {
if (!['image/jpeg', 'image/png', 'image/webp'].includes(contentType)) return null;
if (isAnimated(bytes, contentType)) return null;
try {
renderer ??= new Renderer();
const image = { type: 'image' as const, src: bytes };
const { width, height } = await renderer.measure(image);
if (width <= SIZE && height <= SIZE) return null;
const size = Math.floor(Math.min(SIZE, width, height));
const resized = await renderer.render(
{ ...image, width: size, height: size, style: { objectFit: 'cover' } },
{ width: size, height: size, format: 'webp', quality: 80 }
);
return resized.byteLength < bytes.byteLength ? resized : null;
} catch {
// Unsupported/corrupt inputs keep the original rather than breaking the avatar.
return null;
}
}
async function createThumbnail(bytes: Buffer, contentType: string): Promise<AvatarThumbnail> {
const resized = await tryResizeAvatar(bytes, contentType);
const output = resized ?? bytes;
const outputContentType = resized ? 'image/webp' : contentType;
return {
bytes: Uint8Array.from(output),
contentType: outputContentType,
etag: `"${digest(output)}"`
};
}
async function loadAvatarThumbnail(
fetchFn: typeof fetch,
source: string
): Promise<AvatarThumbnail> {
const cacheBeforeFetch = cachedAvatar?.source === source ? cachedAvatar : null;
const headers = new Headers({ 'accept-encoding': 'identity' });
if (cacheBeforeFetch?.sourceEtag) headers.set('if-none-match', cacheBeforeFetch.sourceEtag);
const response = await fetchFn(source, { headers, redirect: 'error' });
if (response.status === 304 && cacheBeforeFetch) return cacheBeforeFetch.thumbnail;
const encoding = response.headers.get('content-encoding');
if (encoding && encoding.toLowerCase() !== 'identity') {
await response.body?.cancel();
throw new Error('Avatar source ignored identity encoding.');
}
if (!response.ok) {
await response.body?.cancel();
throw new Error(`Avatar source returned HTTP ${response.status}.`);
}
// Kit's dev asset reader can omit MIME headers for files outside static/.
const contentType = (
response.headers.get('content-type') ??
lookup(new URL(source, 'http://when.invalid').pathname) ??
''
)
.split(';', 1)[0]
.trim()
.toLowerCase();
if (!contentType.startsWith('image/')) {
await response.body?.cancel();
throw new Error('Avatar source is not an image.');
}
const bytes = await readSource(response);
const sourceDigest = digest(bytes);
const sourceEtag = response.headers.get('etag');
const cacheAfterRead = cachedAvatar;
if (
cacheAfterRead?.source === source &&
cacheAfterRead.contentType === contentType &&
cacheAfterRead.sourceDigest === sourceDigest
) {
cacheAfterRead.sourceEtag = sourceEtag;
return cacheAfterRead.thumbnail;
}
const thumbnail = await createThumbnail(bytes, contentType);
cachedAvatar = { source, contentType, sourceDigest, sourceEtag, thumbnail };
return thumbnail;
}
/** Revalidate between requests; concurrent callers share the entire load and resize. */
export function getAvatarThumbnail(
fetchFn: typeof fetch,
source: string
): Promise<AvatarThumbnail> {
const inFlight = inFlightAvatars.get(source);
if (inFlight) return inFlight;
const thumbnailPromise = loadAvatarThumbnail(fetchFn, source).finally(() => {
inFlightAvatars.delete(source);
});
inFlightAvatars.set(source, thumbnailPromise);
return thumbnailPromise;
}
+6 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import IconShieldCheck from 'virtual:icons/ph/shield-check';
import ThemeToggle from '#lib/components/ThemeToggle.svelte';
import { avatarThumbnailUrl } from '#lib/avatar.js';
let { data } = $props();
</script>
@@ -14,7 +15,11 @@
<div class="profile">
{#if data.user.appearance.avatar_path}
<div class="avatar-container">
<img src={data.user.appearance.avatar_path} alt={data.user.name} class="avatar" />
<img
src={avatarThumbnailUrl(data.user.appearance.avatar_path)}
alt={data.user.name}
class="avatar"
/>
{#if data.role === 'host'}
<a
href="/admin"
@@ -0,0 +1,93 @@
import { error } from '@sveltejs/kit';
import { lookup } from 'mrmime';
import { getAvatarThumbnail, type AvatarThumbnail } from '#lib/server/avatar.js';
import { getConfig } from '#lib/server/state.js';
import type { RequestHandler } from './$types';
async function fetchOriginalAvatar(
fetchSource: typeof fetch,
source: URL,
requestHeaders: Headers
): Promise<Response> {
const headers = new Headers({ 'accept-encoding': 'identity' });
for (const name of ['if-none-match', 'if-modified-since']) {
const value = requestHeaders.get(name);
if (value) headers.set(name, value);
}
const original = await fetchSource(`${source.pathname}${source.search}`, {
headers,
redirect: 'error'
});
// Native fetch decodes compressed bodies but retains their encoding/length headers.
// If the source ignores identity, let the browser load it directly instead.
const encoding = original.headers.get('content-encoding');
if (encoding && encoding.toLowerCase() !== 'identity') {
await original.body?.cancel();
throw new Error('Avatar source ignored identity encoding.');
}
const responseHeaders = new Headers(original.headers);
if (!responseHeaders.has('content-type')) {
const type = lookup(source.pathname);
if (type) responseHeaders.set('content-type', type);
}
return new Response(original.body, { status: original.status, headers: responseHeaders });
}
function thumbnailResponse(image: AvatarThumbnail, ifNoneMatch: string | null): Response {
const headers = new Headers({
'content-type': image.contentType,
'cache-control': 'public, max-age=3600',
etag: image.etag
});
const matches = ifNoneMatch?.split(',').some((tag) => {
const value = tag.trim();
return value === '*' || value.replace(/^W\//, '') === image.etag;
});
if (matches) return new Response(null, { status: 304, headers });
headers.set('content-length', String(image.bytes.byteLength));
return new Response(image.bytes, { headers });
}
export const GET: RequestHandler = async ({ fetch, request, url, params }) => {
const thumbnail = url.searchParams.get('thumbnail');
if (thumbnail !== null && thumbnail !== 'true' && thumbnail !== 'false') {
error(400, 'thumbnail must be true or false.');
}
const avatarPath = getConfig().user.appearance.avatar_path;
const source = new URL(avatarPath, url);
// Only the configured same-origin asset is eligible.
if (
!avatarPath.startsWith('/') ||
source.pathname.startsWith('//') ||
source.origin !== url.origin
) {
error(404);
}
let sourcePathname: string;
try {
sourcePathname = decodeURIComponent(source.pathname).replace(/\/+$/, '');
} catch {
error(404);
}
// The avatar route cannot fetch itself as its source.
if (sourcePathname === '/avatar' || sourcePathname.startsWith('/avatar/')) {
error(404);
}
// The request path only validates cache identity; it never selects a source.
if (!params.path || `/${params.path}` !== sourcePathname) {
error(404);
}
const sourcePath = `${source.pathname}${source.search}`;
try {
if (thumbnail !== 'true') return await fetchOriginalAvatar(fetch, source, request.headers);
const image = await getAvatarThumbnail(fetch, sourcePath);
return thumbnailResponse(image, request.headers.get('if-none-match'));
} catch {
// A transient failure must not hide an otherwise usable original or cache a redirect.
return new Response(null, {
status: 307,
headers: { location: sourcePath, 'cache-control': 'no-store' }
});
}
};
@@ -0,0 +1,276 @@
import { once } from 'node:events';
import { createServer } from 'node:http';
import { gzipSync } from 'node:zlib';
import { beforeEach, expect, test, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
source: '/public/avatar.jpg',
thumbnail: vi.fn()
}));
vi.mock('#lib/server/state.js', () => ({
getConfig: () => ({ user: { appearance: { avatar_path: mocks.source } } })
}));
vi.mock('#lib/server/avatar.js', () => ({ getAvatarThumbnail: mocks.thumbnail }));
import { GET } from './+server';
function createRequestEvent({
headers = {},
search = '?thumbnail=true',
fetchSource = vi.fn<typeof fetch>(),
path = 'public/avatar.jpg'
}: {
headers?: Record<string, string>;
search?: string;
fetchSource?: typeof fetch;
path?: string;
} = {}) {
const url = new URL(`https://when.example/avatar/${path}${search}`);
return {
url,
params: { path: decodeURIComponent(path) },
request: new Request(url, { headers }),
fetch: fetchSource
} as unknown as Parameters<typeof GET>[0];
}
beforeEach(() => {
mocks.source = '/public/avatar.jpg';
mocks.thumbnail.mockReset().mockResolvedValue({
bytes: new Uint8Array([1, 2, 3]),
contentType: 'image/webp',
etag: '"thumbnail"'
});
});
test.each(['', '?thumbnail=false'])(
'serves the original without resizing for %s',
async (search) => {
const fetchSource = vi.fn<typeof fetch>().mockResolvedValue(
new Response(new Uint8Array([4, 5, 6]), {
headers: {
'content-type': 'image/jpeg',
'cache-control': 'public, max-age=3600',
etag: '"original"'
}
})
);
const response = await GET(createRequestEvent({ search, fetchSource }));
expect(fetchSource).toHaveBeenCalledWith(
'/public/avatar.jpg',
expect.objectContaining({ redirect: 'error' })
);
expect(mocks.thumbnail).not.toHaveBeenCalled();
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('image/jpeg');
expect(response.headers.get('cache-control')).toBe('public, max-age=3600');
expect(response.headers.get('etag')).toBe('"original"');
expect(Array.from(new Uint8Array(await response.arrayBuffer()))).toEqual([4, 5, 6]);
}
);
test.each([
{ behavior: 'honors identity encoding', ignoreIdentity: false },
{ behavior: 'ignores identity encoding', ignoreIdentity: true }
])('safely forwards an upstream that $behavior', async ({ ignoreIdentity }) => {
const source = Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><rect width="40" height="40" fill="green"/></svg>'
);
const compressed = gzipSync(source);
const encodings: (string | undefined)[] = [];
const server = createServer((request, response) => {
encodings.push(request.headers['accept-encoding']);
const useGzip = ignoreIdentity || request.headers['accept-encoding'] !== 'identity';
const body = useGzip ? compressed : source;
response.writeHead(200, {
'content-type': 'image/svg+xml',
'content-length': String(body.byteLength),
...(useGzip ? { 'content-encoding': 'gzip' } : {})
});
response.end(body);
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
try {
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Expected a TCP address');
const sourceUrl = `http://127.0.0.1:${address.port}/image.svg`;
// Reproduce native fetch's decoded body with still-compressed metadata.
const probe = await fetch(sourceUrl, { headers: { 'accept-encoding': 'gzip' } });
expect(probe.headers.get('content-encoding')).toBe('gzip');
expect(Buffer.from(await probe.arrayBuffer())).toEqual(source);
mocks.source = '/public/avatar.svg';
const fetchSource = vi
.fn<typeof fetch>()
.mockImplementation((_input, init) => fetch(sourceUrl, init));
const response = await GET(
createRequestEvent({ search: '', path: 'public/avatar.svg', fetchSource })
);
expect(encodings.at(-1)).toBe('identity');
expect(mocks.thumbnail).not.toHaveBeenCalled();
if (ignoreIdentity) {
expect(response.status).toBe(307);
expect(response.headers.get('location')).toBe('/public/avatar.svg');
expect(response.headers.get('cache-control')).toBe('no-store');
} else {
expect(response.status).toBe(200);
expect(response.headers.get('content-encoding')).toBeNull();
expect(response.headers.get('content-length')).toBe(String(source.byteLength));
expect(Buffer.from(await response.arrayBuffer())).toEqual(source);
}
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
test('restores the full image MIME when dev fetch omits its headers', async () => {
const fetchSource = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response(new Uint8Array([4, 5, 6])));
const response = await GET(createRequestEvent({ search: '', fetchSource }));
expect(response.headers.get('content-type')).toBe('image/jpeg');
expect(Array.from(new Uint8Array(await response.arrayBuffer()))).toEqual([4, 5, 6]);
});
test('forwards original-image validators and preserves a source 304 response', async () => {
const fetchSource = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response(null, { status: 304, headers: { etag: '"original"' } }));
const response = await GET(
createRequestEvent({
search: '',
headers: {
'if-none-match': '"original"',
'if-modified-since': 'Mon, 02 Nov 2026 00:00:00 GMT'
},
fetchSource
})
);
const headers = new Headers(fetchSource.mock.calls[0][1]?.headers);
expect(headers.get('if-none-match')).toBe('"original"');
expect(headers.get('if-modified-since')).toBe('Mon, 02 Nov 2026 00:00:00 GMT');
expect(response.status).toBe(304);
expect(response.headers.get('etag')).toBe('"original"');
expect(await response.text()).toBe('');
expect(mocks.thumbnail).not.toHaveBeenCalled();
});
test.each(['', '1', 'full'])('rejects an unsupported thumbnail parameter: %s', async (value) => {
await expect(GET(createRequestEvent({ search: `?thumbnail=${value}` }))).rejects.toMatchObject({
status: 400
});
expect(mocks.thumbnail).not.toHaveBeenCalled();
});
test('serves the configured avatar derivative with cache validators', async () => {
const requestEvent = createRequestEvent();
const response = await GET(requestEvent);
expect(mocks.thumbnail).toHaveBeenCalledWith(requestEvent.fetch, '/public/avatar.jpg');
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('image/webp');
expect(response.headers.get('content-length')).toBe('3');
expect(response.headers.get('cache-control')).toBe('public, max-age=3600');
expect(response.headers.get('etag')).toBe('"thumbnail"');
expect(Array.from(new Uint8Array(await response.arrayBuffer()))).toEqual([1, 2, 3]);
});
test.each(['"thumbnail"', 'W/"thumbnail"', '"another", "thumbnail"', '*'])(
'returns 304 for a matching If-None-Match: %s',
async (etag) => {
const response = await GET(createRequestEvent({ headers: { 'if-none-match': etag } }));
expect(response.status).toBe(304);
expect(response.headers.get('etag')).toBe('"thumbnail"');
expect(response.headers.has('content-length')).toBe(false);
expect(await response.text()).toBe('');
}
);
test('a changed derivative does not match a stale client validator', async () => {
const response = await GET(
createRequestEvent({ headers: { 'if-none-match': '"old-thumbnail"' } })
);
expect(response.status).toBe(200);
});
test('preserves query parameters on a configured local source', async () => {
mocks.source = '/public/avatar.jpg?revision=2';
const requestEvent = createRequestEvent();
await GET(requestEvent);
expect(mocks.thumbnail).toHaveBeenCalledWith(requestEvent.fetch, mocks.source);
});
test('query parameters cannot select an arbitrary image source', async () => {
const requestEvent = createRequestEvent();
requestEvent.url.searchParams.set('source', 'https://elsewhere.example/image.jpg');
await GET(requestEvent);
expect(mocks.thumbnail).toHaveBeenCalledWith(requestEvent.fetch, '/public/avatar.jpg');
});
test('source failures redirect to the original without caching the failure', async () => {
mocks.thumbnail.mockRejectedValueOnce(new Error('source unavailable'));
const response = await GET(createRequestEvent());
expect(response.status).toBe(307);
expect(response.headers.get('location')).toBe('/public/avatar.jpg');
expect(response.headers.get('cache-control')).toBe('no-store');
});
test.each(['', '?thumbnail=true'])(
'rejects a mismatched or missing validation path for %s',
async (search) => {
const fetchSource = vi.fn<typeof fetch>();
for (const path of ['public/another.jpg', '']) {
await expect(GET(createRequestEvent({ search, fetchSource, path }))).rejects.toMatchObject({
status: 404
});
}
expect(fetchSource).not.toHaveBeenCalled();
expect(mocks.thumbnail).not.toHaveBeenCalled();
}
);
test('validates a decoded filename but fetches the configured encoded source', async () => {
mocks.source = '/public/profile photo.jpg?revision=2';
const requestEvent = createRequestEvent({ path: 'public/profile%20photo.jpg' });
await GET(requestEvent);
expect(mocks.thumbnail).toHaveBeenCalledWith(
requestEvent.fetch,
'/public/profile%20photo.jpg?revision=2'
);
});
test.each(['', '?thumbnail=true'])(
'rejects configured sources in the avatar route namespace for %s',
async (search) => {
const fetchSource = vi.fn<typeof fetch>();
for (const source of ['/%61vatar', '/avatar/public/avatar.jpg']) {
mocks.source = source;
await expect(
GET(createRequestEvent({ search, fetchSource, path: source.slice(1) }))
).rejects.toMatchObject({ status: 404 });
}
expect(fetchSource).not.toHaveBeenCalled();
expect(mocks.thumbnail).not.toHaveBeenCalled();
}
);
test.each([
'https://elsewhere.example/avatar.jpg',
'/\t/elsewhere.example/avatar.jpg',
'/folder/..//elsewhere.example/avatar.png',
'/avatar',
'/avatar?thumbnail=true',
'/%61vatar',
'/avatar/'
])('rejects off-origin or recursive configured sources: %s', async (source) => {
mocks.source = source;
const path = new URL(source, 'https://when.example').pathname.slice(1);
const fetchSource = vi.fn<typeof fetch>();
await expect(GET(createRequestEvent({ fetchSource, path }))).rejects.toMatchObject({
status: 404
});
expect(fetchSource).not.toHaveBeenCalled();
expect(mocks.thumbnail).not.toHaveBeenCalled();
});
+25
View File
@@ -154,6 +154,31 @@ All `*_path` values must be root-relative paths served by this app. Remote URLs
are rejected. Put custom files in `./public/`, alongside `config/` and `data/`,
and reference them as `/public/logo.png`. `WHEN_PUBLIC_DIR` overrides that directory.
For an `avatar_path` of `/public/photo.webp`, `/avatar/public/photo.webp` serves
the original and `/avatar/public/photo.webp?thumbnail=true` serves a thumbnail.
The path is only checked against the configured avatar: mismatches return 404,
and the app always loads the source selected by configuration, never by the
request path. Changing the configured path changes the browser's cache key
without embedding a path in a query parameter.
Web pages request thumbnails automatically, resizing raster avatars to at most
216 × 216 pixels (enough for the largest 72-pixel avatar at 3× density).
`thumbnail=false` also serves the full image; other values are rejected. Keep
setting only `avatar_path`; no separate thumbnail or image tool is needed.
Generated share cards continue to load the original image.
Thumbnail responses preserve SVGs, small images and animations. It uses WebP
only when the result is smaller, and falls back to the original on conversion
failure. Sources above 8 MiB are served directly instead of processed. The app
caches the current derivative in memory and revalidates its source when the route
is requested. Concurrent requests share source loading and resizing. Browser
caching lasts up to one hour; changing `avatar_path` also
changes the web thumbnail's cache key. The source file is never overwritten.
**Known limitation:** Takumi currently ignores EXIF orientation metadata. A photo
that relies on that metadata may appear rotated in the generated thumbnail. This
also occurs with Takumi 2.19.0; it is not corrected by this optimization.
Colors accept three- or six-digit hex values. Each primary color generates a
muted tonal scale for its mode; background and text colors override the surfaces
and text.
+54 -54
View File
@@ -168,8 +168,8 @@ importers:
specifier: 10.3.1
version: 10.3.1
takumi-js:
specifier: 2.2.0
version: 2.2.0(preact@10.24.3)
specifier: 2.19.0
version: 2.19.0(preact@10.24.3)
temporal-polyfill:
specifier: 1.0.1
version: 1.0.1
@@ -1207,60 +1207,60 @@ packages:
'@swc/helpers@0.5.23':
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
'@takumi-rs/core-darwin-arm64@2.2.0':
resolution: {integrity: sha512-6HhAaf63mrjX+q3yQXX0SZVU18kSuzr35nAcTsqqKGIr/jsoUMAygiLRDTaufpSlCo7lL4TkUmcIi8/vl3fHVg==}
'@takumi-rs/core-darwin-arm64@2.19.0':
resolution: {integrity: sha512-W/SI7Zrxo70dIaKNo+/kOLVr97S3ezkqsi7OM0RWz2CtP+cFgdBTPHXNsrB561gJFPHsuWN/LZ0u8oZxeAYtGw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@takumi-rs/core-darwin-x64@2.2.0':
resolution: {integrity: sha512-JSv1m+9m3xxCBfJFQ29ACU+ITHV0avDj5LQTOLdmIifRBv4x+JogjWw2ZRq5PisJ8/vKA/QklDqOGeJhxHcw2g==}
'@takumi-rs/core-darwin-x64@2.19.0':
resolution: {integrity: sha512-xO/eTwPLrfUZRybMnG4lb6fDa7BjFryFi1TSkRF5KjPhkv21Lr5ovFEYz1y2P5k/4psrk66FJbU2EJDOEjdXiw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@takumi-rs/core-linux-arm64-gnu@2.2.0':
resolution: {integrity: sha512-fXWs3q8iJiSToCvgjv2/9h8/3FxmBB5ba32mNOoFP+zD8kEQDNYoQ/TfLUqDWoGTVPvjGL+SLwp3PxydFa1Nfw==}
'@takumi-rs/core-linux-arm64-gnu@2.19.0':
resolution: {integrity: sha512-0PvL5bNTZTkLExjKveI3pOz3mtfjJtECqK49f/KGnp7J0bBmdoi2muszCh/dQQEEFKXcWNe27yfK1tzsO5fm0g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@takumi-rs/core-linux-arm64-musl@2.2.0':
resolution: {integrity: sha512-O+FBMMAXK7r57oVH6dFhZ+4clWRtZ6LeIpzLsAz8FFYU4c9cwGoTBJ8BideNJzt2S78GDQaG9qy5wM+pvkveYA==}
'@takumi-rs/core-linux-arm64-musl@2.19.0':
resolution: {integrity: sha512-nwKZMGcNVdmCP+U7+uINtXpgOeyk5m7g5D9EAtttpWc6ImjJm7MaqNK4Q2YBkOFeAIbRkY5dDL8Xcu19ATSj0w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@takumi-rs/core-linux-x64-gnu@2.2.0':
resolution: {integrity: sha512-zRy83L9R0SIr0t4AcRzpmbRhCQsB/XZzgjDJ/szA3dytX9jDtVP1WpcoyFRBJFTrmXDFsAIEBoKTobV5vs427w==}
'@takumi-rs/core-linux-x64-gnu@2.19.0':
resolution: {integrity: sha512-gf/U6qxWZk6uX5MFnl5/PTOzxBbISj89UQwbliHa1sflcq2d+SeITDqnGtkUueF/4TlgPVV7JvrPuhAbINy2Gg==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@takumi-rs/core-linux-x64-musl@2.2.0':
resolution: {integrity: sha512-K+14jFA7XQXdrMu3gOToIzZvcP6DK0f0FZMH3HQ7bQfRn1Hx9gS1KesILjpH/C4CF+ZK5kQhxwVjoxeUzBSeYA==}
'@takumi-rs/core-linux-x64-musl@2.19.0':
resolution: {integrity: sha512-zHD0EdIA2ZMCnKV/KtMgEerUcdvjYU4hGiru9I/x96u+BJXaQGMtHCxXt9Srhj+dlqhPyWdQTMAE3wpflN+0nw==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
libc: [musl]
'@takumi-rs/core-win32-arm64-msvc@2.2.0':
resolution: {integrity: sha512-CPIVzIMCcTP6Bd9TDn1eB6NNwMViIRjyOCexH2fdJj3/rzfp0/A1EDkfFMXMmItFomwGpe7f/2I0A5Vvujnzbg==}
'@takumi-rs/core-win32-arm64-msvc@2.19.0':
resolution: {integrity: sha512-v1CrPsmzhIe0+v+kIAdaSwrNMqiOpUXgMxHLUo0xDQdPMNML0EefDsL9ADtKDpT1pjhY/r9tvwVl3RhKsdottw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@takumi-rs/core-win32-x64-msvc@2.2.0':
resolution: {integrity: sha512-IATwTX+ScVtHECIAUw7+xXZmu/lCZylHHCNzpIQUnzAxRjT3iu9E8WHQvNlCtxDnYq1L3AhuuwmJoh+2cT7BaA==}
'@takumi-rs/core-win32-x64-msvc@2.19.0':
resolution: {integrity: sha512-9HJb2LTDSlehFrGfPw+uKgxM3XCnpy/PYnwtyzcgAiSFJVjwv0zCLl3IvJREakj5Y9rJCg0mMtPje9hlPbJL/A==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@takumi-rs/core@2.2.0':
resolution: {integrity: sha512-Ex393O45Su8Xm0DqqtFPDnhF93neUzUX5JlVmJ4R95dVY7NQyJkAJ2kZ/Uok8EyWI92G5AEFlkglarrkP3/jwQ==}
'@takumi-rs/core@2.19.0':
resolution: {integrity: sha512-cYxOTb4pOu25Y8GBm3JTyIwenHNAZF11pxEmTxA3Q7VabC0pqeWDo/nOjTc2slTHORaglkvwK4SMmnpzab5BjQ==}
engines: {node: '>=18'}
peerDependencies:
csstype: '*'
@@ -1268,11 +1268,11 @@ packages:
csstype:
optional: true
'@takumi-rs/helpers@2.2.0':
resolution: {integrity: sha512-U9Cr4x/BN/GDFcmIOQs3XdiBQvidbv7rsSIjpol/ICN2EOvNaSnXMm9wwtBr4UgkQ2IhMzcTmkgrCMx8LROjwg==}
'@takumi-rs/helpers@2.19.0':
resolution: {integrity: sha512-3ycCjYCP31HACXBvlpv4fMLw9vneCVGuttFBfZr4jpL0nHNCt2wlXvt0XFt05FscLMUY2s+5PGKPE6EatXI/mg==}
engines: {node: '>=18'}
peerDependencies:
preact: ^10.0.0
preact: ^10.0.0 || ^11.0.0
react: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
preact:
@@ -1280,9 +1280,9 @@ packages:
react:
optional: true
'@takumi-rs/wasm@2.2.0':
resolution: {integrity: sha512-6xizlgurJNkynwFZBI/ercSf2mlYryVgp0kl0KK6yV10rahs5ECVGH8TcSD9gtzi3YBnxCNkdD6oAw1K5+ApNA==}
engines: {node: '>=18'}
'@takumi-rs/wasm@2.19.0':
resolution: {integrity: sha512-V9LKPOFVHKZ7lANVQ94WmOkGvgW0EqXUeLEpsWB2Wnnx7WpOWHnbAI9Yb0VnAYeyRicp7nAfG3vF/M7vsw0s2w==}
engines: {node: '>=20.19'}
peerDependencies:
csstype: '*'
peerDependenciesMeta:
@@ -2322,9 +2322,9 @@ packages:
tabbable@6.4.0:
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
takumi-js@2.2.0:
resolution: {integrity: sha512-EGw1bJ1U/GfISJi5hs6G98dLfk1QId90z+buXrRTFs8MAJTHTxdno+0jX0eamzXueT8AGLfdI0nSq47Dw1uSUQ==}
engines: {node: '>=18'}
takumi-js@2.19.0:
resolution: {integrity: sha512-WUgzukIpAtoDUeFkdKvT0o4pterWoyM9qcXpIRUQIqUZHriIBdPdPZV42UyzEbNyXvg/1aUaVhBGEyivraT8NQ==}
engines: {node: '>=20.19'}
tdigest@0.1.2:
resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==}
@@ -3100,53 +3100,53 @@ snapshots:
dependencies:
tslib: 2.8.1
'@takumi-rs/core-darwin-arm64@2.2.0':
'@takumi-rs/core-darwin-arm64@2.19.0':
optional: true
'@takumi-rs/core-darwin-x64@2.2.0':
'@takumi-rs/core-darwin-x64@2.19.0':
optional: true
'@takumi-rs/core-linux-arm64-gnu@2.2.0':
'@takumi-rs/core-linux-arm64-gnu@2.19.0':
optional: true
'@takumi-rs/core-linux-arm64-musl@2.2.0':
'@takumi-rs/core-linux-arm64-musl@2.19.0':
optional: true
'@takumi-rs/core-linux-x64-gnu@2.2.0':
'@takumi-rs/core-linux-x64-gnu@2.19.0':
optional: true
'@takumi-rs/core-linux-x64-musl@2.2.0':
'@takumi-rs/core-linux-x64-musl@2.19.0':
optional: true
'@takumi-rs/core-win32-arm64-msvc@2.2.0':
'@takumi-rs/core-win32-arm64-msvc@2.19.0':
optional: true
'@takumi-rs/core-win32-x64-msvc@2.2.0':
'@takumi-rs/core-win32-x64-msvc@2.19.0':
optional: true
'@takumi-rs/core@2.2.0(preact@10.24.3)':
'@takumi-rs/core@2.19.0(preact@10.24.3)':
dependencies:
'@takumi-rs/helpers': 2.2.0(preact@10.24.3)
'@takumi-rs/helpers': 2.19.0(preact@10.24.3)
optionalDependencies:
'@takumi-rs/core-darwin-arm64': 2.2.0
'@takumi-rs/core-darwin-x64': 2.2.0
'@takumi-rs/core-linux-arm64-gnu': 2.2.0
'@takumi-rs/core-linux-arm64-musl': 2.2.0
'@takumi-rs/core-linux-x64-gnu': 2.2.0
'@takumi-rs/core-linux-x64-musl': 2.2.0
'@takumi-rs/core-win32-arm64-msvc': 2.2.0
'@takumi-rs/core-win32-x64-msvc': 2.2.0
'@takumi-rs/core-darwin-arm64': 2.19.0
'@takumi-rs/core-darwin-x64': 2.19.0
'@takumi-rs/core-linux-arm64-gnu': 2.19.0
'@takumi-rs/core-linux-arm64-musl': 2.19.0
'@takumi-rs/core-linux-x64-gnu': 2.19.0
'@takumi-rs/core-linux-x64-musl': 2.19.0
'@takumi-rs/core-win32-arm64-msvc': 2.19.0
'@takumi-rs/core-win32-x64-msvc': 2.19.0
transitivePeerDependencies:
- preact
- react
'@takumi-rs/helpers@2.2.0(preact@10.24.3)':
'@takumi-rs/helpers@2.19.0(preact@10.24.3)':
optionalDependencies:
preact: 10.24.3
'@takumi-rs/wasm@2.2.0(preact@10.24.3)':
'@takumi-rs/wasm@2.19.0(preact@10.24.3)':
dependencies:
'@takumi-rs/helpers': 2.2.0(preact@10.24.3)
'@takumi-rs/helpers': 2.19.0(preact@10.24.3)
transitivePeerDependencies:
- preact
- react
@@ -4154,11 +4154,11 @@ snapshots:
tabbable@6.4.0: {}
takumi-js@2.2.0(preact@10.24.3):
takumi-js@2.19.0(preact@10.24.3):
dependencies:
'@takumi-rs/core': 2.2.0(preact@10.24.3)
'@takumi-rs/helpers': 2.2.0(preact@10.24.3)
'@takumi-rs/wasm': 2.2.0(preact@10.24.3)
'@takumi-rs/core': 2.19.0(preact@10.24.3)
'@takumi-rs/helpers': 2.19.0(preact@10.24.3)
'@takumi-rs/wasm': 2.19.0(preact@10.24.3)
transitivePeerDependencies:
- csstype
- preact