feat: static server support etag

This commit is contained in:
master 2025-06-18 04:42:33 +08:00
parent cc06142050
commit c12b9b360a
7 changed files with 114 additions and 17 deletions

View File

@ -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)
)

View File

@ -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)

View File

@ -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": {

View File

@ -1,16 +1,18 @@
import { type ComponentProps } from "react";
import { type ComponentProps, useMemo, useState } from "react";
export type ImgProps = Omit<ComponentProps<"img">, "alt"> &
Required<Pick<ComponentProps<"img">, "alt">> & {
optimize?: boolean;
};
// biome-ignore lint/correctness/noUnusedVariables: <explanation>
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: <explanation>
return <img {...props} alt={props.alt} />;
@ -18,7 +20,19 @@ export const Img = (props: ImgProps) => {
return (
<picture {...props}>
<img {...props} alt={props.alt} />
{isLegacy && !isError && (
<>
<source
srcSet={src.replace(LEGACY_IMAGE_REGEX, ".webp")}
type="image/webp"
/>
<source
srcSet={src.replace(LEGACY_IMAGE_REGEX, ".avif")}
type="image/avif"
/>
</>
)}
<img {...props} alt={props.alt} onError={() => setIsError(true)} />
</picture>
);
};

View File

@ -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

View File

@ -19,6 +19,9 @@
"engines": {
"node": ">=22"
},
"dependencies": {
"es-toolkit": "^1.39.3"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@types/node": "^24.0.1",

7
pnpm-lock.yaml generated
View File

@ -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))