From c12b9b360a086d5459db35f9c17932060dc0efcc Mon Sep 17 00:00:00 2001 From: lonelyhentxi Date: Wed, 18 Jun 2025 04:42:33 +0800 Subject: [PATCH] feat: static server support etag --- apps/recorder/examples/optimize_image.mjs | 45 ++++++++++++++++++++ apps/recorder/src/storage/client.rs | 52 ++++++++++++++++++----- apps/webui/package.json | 1 - apps/webui/src/components/ui/img.tsx | 20 +++++++-- justfile | 3 ++ package.json | 3 ++ pnpm-lock.yaml | 7 +-- 7 files changed, 114 insertions(+), 17 deletions(-) create mode 100644 apps/recorder/examples/optimize_image.mjs diff --git a/apps/recorder/examples/optimize_image.mjs b/apps/recorder/examples/optimize_image.mjs new file mode 100644 index 0000000..3f5e220 --- /dev/null +++ b/apps/recorder/examples/optimize_image.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env zx +import { glob } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { chunk } from 'es-toolkit/array'; + +const dataDir = path.join(import.meta.dirname, '../../../data') +/** + * @type {string[]} + */ +const images = []; +for await (const image of glob('**/*.{jpg,jpeg,png,gif,svg}', { + cwd: dataDir, +})) { + images.push(image) +} + +const cpus = os.cpus().length - 1; + +const chunkSize = Math.ceil(images.length / cpus); +const chunks = chunk(images, chunkSize); + +/** + * @param {string[]} images + */ +async function convertImages(images) { + for await (const image of images) { + const imagePath = path.resolve(dataDir, image) + const webp = imagePath.replace(path.extname(imagePath), '.webp') + const avif = imagePath.replace(path.extname(imagePath), '.avif') + console.log(`Converting ${imagePath} to ${webp}...`); + await $`ffmpeg -i "${imagePath}" -c:v libwebp -lossless 1 "${webp}"`; + console.log(`Converting ${imagePath} to ${avif}...`); + await $`ffmpeg -i "${imagePath}" -c:v libaom-av1 -still-picture 1 -pix_fmt yuv420p10le -crf 0 -strict experimental "${avif}"`; + } +} + +await Promise.all( + chunks.map(convertImages) +) + + + + + diff --git a/apps/recorder/src/storage/client.rs b/apps/recorder/src/storage/client.rs index c897528..918015c 100644 --- a/apps/recorder/src/storage/client.rs +++ b/apps/recorder/src/storage/client.rs @@ -1,4 +1,4 @@ -use std::fmt; +use std::{borrow::Cow, fmt}; use async_stream::try_stream; use axum::{body::Body, response::Response}; @@ -206,6 +206,12 @@ impl StorageService { let mime_type = mime_guess::from_path(storage_path.as_ref()).first_or_octet_stream(); let content_type = HeaderValue::from_str(mime_type.as_ref())?; + let etag = metadata.etag().map(Cow::Borrowed).or_else(|| { + let len = metadata.content_length(); + let lm = metadata.last_modified()?.timestamp(); + Some(Cow::Owned(format!("\"{lm:x}-{len:x}\""))) + }); + let last_modified = metadata.last_modified().map(|lm| lm.to_rfc2822()); let response = if let Some(TypedHeader(range)) = range { let ranges = range @@ -240,7 +246,7 @@ impl StorageService { }; let body = Body::from_stream(stream); - Response::builder() + let mut builder = Response::builder() .status(StatusCode::PARTIAL_CONTENT) .header( header::CONTENT_TYPE, @@ -248,17 +254,34 @@ impl StorageService { format!("multipart/byteranges; boundary={boundary}").as_str(), ) .unwrap(), - ) - .body(body)? + ); + + if let Some(etag) = etag { + builder = builder.header(header::ETAG, etag.to_string()); + } + + if let Some(last_modified) = last_modified { + builder = builder.header(header::LAST_MODIFIED, last_modified); + } + + builder.body(body)? } else if let Some((r, content_range)) = ranges.pop() { let reader = self.reader(storage_path.as_ref()).await?; let stream = reader.into_bytes_stream(r).await?; - Response::builder() + let mut builder = Response::builder() .status(StatusCode::PARTIAL_CONTENT) .header(header::CONTENT_TYPE, content_type.clone()) - .header(header::CONTENT_RANGE, content_range) - .body(Body::from_stream(stream))? + .header(header::CONTENT_RANGE, content_range); + + if let Some(etag) = metadata.etag() { + builder = builder.header(header::ETAG, etag); + } + if let Some(last_modified) = last_modified { + builder = builder.header(header::LAST_MODIFIED, last_modified); + } + + builder.body(Body::from_stream(stream))? } else { unreachable!("ranges length should be greater than 0") } @@ -276,10 +299,19 @@ impl StorageService { let reader = self.reader(storage_path.as_ref()).await?; let stream = reader.into_bytes_stream(..).await?; - Response::builder() + let mut builder = Response::builder() .status(StatusCode::OK) - .header(header::CONTENT_TYPE, content_type) - .body(Body::from_stream(stream))? + .header(header::CONTENT_TYPE, content_type); + + if let Some(etag) = etag { + builder = builder.header(header::ETAG, etag.to_string()); + } + + if let Some(last_modified) = last_modified { + builder = builder.header(header::LAST_MODIFIED, last_modified); + } + + builder.body(Body::from_stream(stream))? }; Ok(response) diff --git a/apps/webui/package.json b/apps/webui/package.json index 2e714d8..92d2303 100644 --- a/apps/webui/package.json +++ b/apps/webui/package.json @@ -77,7 +77,6 @@ "tw-animate-css": "^1.3.4", "type-fest": "^4.41.0", "vaul": "^1.1.2", - "es-toolkit": "^1.39.3", "@tanstack/react-router": "^1.121.2" }, "devDependencies": { diff --git a/apps/webui/src/components/ui/img.tsx b/apps/webui/src/components/ui/img.tsx index ff6506f..2815a82 100644 --- a/apps/webui/src/components/ui/img.tsx +++ b/apps/webui/src/components/ui/img.tsx @@ -1,16 +1,18 @@ -import { type ComponentProps } from "react"; +import { type ComponentProps, useMemo, useState } from "react"; export type ImgProps = Omit, "alt"> & Required, "alt">> & { optimize?: boolean; }; -// biome-ignore lint/correctness/noUnusedVariables: const LEGACY_IMAGE_REGEX = /\.(jpg|jpeg|png|gif|svg)$/; export const Img = (props: ImgProps) => { const src = props.src; + const isLegacy = useMemo(() => src?.match(LEGACY_IMAGE_REGEX), [src]); + const [isError, setIsError] = useState(false); + if (!src) { // biome-ignore lint/nursery/noImgElement: return {props.alt}; @@ -18,7 +20,19 @@ export const Img = (props: ImgProps) => { return ( - {props.alt} + {isLegacy && !isError && ( + <> + + + + )} + {props.alt} setIsError(true)} /> ); }; diff --git a/justfile b/justfile index 8d4bc97..eb828b8 100644 --- a/justfile +++ b/justfile @@ -11,6 +11,9 @@ prepare-dev-testcontainers: docker pull ghcr.io/dumtruck/konobangu-testing-torrents:latest docker pull postgres:17-alpine +dev-optimize-images: + npx -y zx apps/recorder/examples/optimize_image.mjs + dev-webui: pnpm run --filter=webui dev diff --git a/package.json b/package.json index 556b3d5..098bf76 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ "engines": { "node": ">=22" }, + "dependencies": { + "es-toolkit": "^1.39.3" + }, "devDependencies": { "@biomejs/biome": "1.9.4", "@types/node": "^24.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c37e7c2..19c60d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,10 @@ overrides: importers: .: + dependencies: + es-toolkit: + specifier: ^1.39.3 + version: 1.39.3 devDependencies: '@biomejs/biome': specifier: 1.9.4 @@ -209,9 +213,6 @@ importers: embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.1.0) - es-toolkit: - specifier: ^1.39.3 - version: 1.39.3 graphiql: specifier: ^4.1.2 version: 4.1.2(@codemirror/language@6.11.1)(@emotion/is-prop-valid@0.8.8)(@types/node@24.0.1)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(graphql-ws@6.0.4(graphql@16.11.0)(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@6.0.5)))(graphql@16.11.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(use-sync-external-store@1.5.0(react@19.1.0))