feat: Add image transforms

This commit is contained in:
Wanjohi
2025-06-05 03:39:15 +03:00
parent 0124af1b70
commit a47dc91b22
12 changed files with 305 additions and 192 deletions

View File

@@ -26,9 +26,6 @@
"@nestri/core": "workspace:",
"actor-core": "^0.8.0",
"hono": "^4.7.8",
"hono-openapi": "^0.4.8",
"steam-session": "*",
"steamcommunity": "^3.48.6",
"steamid": "^2.1.0"
"hono-openapi": "^0.4.8"
}
}

View File

@@ -0,0 +1,137 @@
import { z } from "zod"
import { Hono } from "hono";
import {
S3Client,
GetObjectCommand,
} from "@aws-sdk/client-s3";
import Sharp from "sharp";
import { Resource } from "sst";
import { validator } from "hono-openapi/zod";
import { HTTPException } from "hono/http-exception";
const s3 = new S3Client();
interface TimingMetrics {
download: number;
transform: number;
upload?: number;
}
const formatTimingHeader = (metrics: TimingMetrics): string => {
const timings = [
`img-download;dur=${Math.round(metrics.download)}`,
`img-transform;dur=${Math.round(metrics.transform)}`,
];
if (metrics.upload !== undefined) {
timings.push(`img-upload;dur=${Math.round(metrics.upload)}`);
}
return timings.join(",");
};
export namespace ImageApi {
export const route = new Hono()
.post("/:hash",
validator("json",
z.object({
dpr: z.number().optional(),
width: z.number().optional(),
height: z.number().optional(),
quality: z.number().optional(),
format: z.enum(["avif", "webp", "jpeg"]),
})
),
// validator("header",
// z.object({
// secretKey: z.string(),
// })
// ),
validator("param",
z.object({
hash: z.string(),
})
),
async (c) => {
const input = c.req.valid("json");
const { hash } = c.req.valid("param");
// const secret = c.req.valid("header").secretKey
const metrics: TimingMetrics = {
download: 0,
transform: 0,
};
const downloadStart = performance.now();
let originalImage: Buffer;
let contentType: string;
try {
const getCommand = new GetObjectCommand({
Bucket: Resource.Storage.name,
Key: hash,
});
const response = await s3.send(getCommand);
originalImage = Buffer.from(await response.Body!.transformToByteArray());
contentType = response.ContentType || "image/jpeg";
metrics.download = performance.now() - downloadStart;
} catch (error) {
throw new HTTPException(500, { message: `Error downloading original image:${error}` });
}
const transformStart = performance.now();
let transformedImage: Buffer;
try {
let sharpInstance = Sharp(originalImage, {
failOn: "none",
animated: true,
});
const metadata = await sharpInstance.metadata();
// Apply transformations
if (input.width || input.height) {
sharpInstance = sharpInstance.resize({
width: input.width,
height: input.height,
});
}
if (metadata.orientation) {
sharpInstance = sharpInstance.rotate();
}
if (input.format) {
const isLossy = ["jpeg", "webp", "avif"].includes(input.format);
if (isLossy && input.quality) {
sharpInstance = sharpInstance.toFormat(input.format, {
quality: input.quality,
});
} else {
sharpInstance = sharpInstance.toFormat(input.format);
}
}
transformedImage = await sharpInstance.toBuffer();
metrics.transform = performance.now() - transformStart;
contentType = `image/${input.format}`;
} catch (error) {
throw new HTTPException(500, { message: `Error transforming image:${error}` });
}
return c.newResponse(transformedImage,
200,
{
"Content-Type": contentType,
"Cache-Control": "max-age=31536000",
"Server-Timing": formatTimingHeader(metrics),
},
)
}
)
}

View File

@@ -2,6 +2,7 @@ import "zod-openapi/extend";
import { cors } from "hono/cors";
import { GameApi } from "./game";
import { SteamApi } from "./steam";
import { ImageApi } from "./image";
import { auth } from "./utils/auth";
import { FriendApi } from "./friend";
import { logger } from "hono/logger";
@@ -25,9 +26,13 @@ app
})
.use(auth)
// Private routes
app.
route("/image", ImageApi.route)
const routes = app
.get("/", (c) => c.text("Hello World!"))
.route("/games",GameApi.route)
.route("/games", GameApi.route)
.route("/steam", SteamApi.route)
.route("/realtime", Realtime.route)
.route("/friends", FriendApi.route)
@@ -77,7 +82,7 @@ app.get(
scheme: "bearer",
bearerFormat: "JWT",
},
TeamID: {
SteamID: {
type: "apiKey",
description: "The steam ID to use for this query",
in: "header",
@@ -85,7 +90,7 @@ app.get(
},
},
},
security: [{ Bearer: [], TeamID: [] }],
security: [{ Bearer: [], SteamID: [] }],
servers: [
{ description: "Production", url: "https://api.nestri.io" },
{ description: "Sandbox", url: "https://api.dev.nestri.io" },
@@ -98,7 +103,7 @@ export default {
port: 3001,
idleTimeout: 255,
webSocketHandler: Realtime.webSocketHandler,
fetch: (req: Request,env: Env) =>
fetch: (req: Request, env: Env) =>
app.fetch(req, env, {
waitUntil: (fn) => fn,
passThroughOnException: () => { },

View File

@@ -1,12 +1,12 @@
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import { Resource } from "sst";
import { HTTPException } from "hono/http-exception";
export namespace ImageRoute {
export const route = new Hono()
.get(
"/:hashWithExt",
(c) => {
async (c) => {
const { hashWithExt } = c.req.param();
// Validate format
@@ -34,17 +34,36 @@ export namespace ImageRoute {
throw new HTTPException(400, { message: "Invalid dpr" });
}
const image = `${Resource.ImageRouter.url}/images/00641801e0f80a82bbbbf9dec5593cd7cbe2a5eec45d36199a5c14ed30d8df66`
console.log("url",Resource.Api.url)
const imageBytes = await fetch(`${Resource.Api.url}/image/${hash}`,{
method:"POST",
body:JSON.stringify({
dpr,
width,
height,
format
})
})
console.log("imahe",imageBytes.headers)
// Normalize and build cache key
const cacheKey = `${hash}_${format}_w${width}${height ? `_h${height}` : ""}_dpr${dpr}`;
// const cacheKey = `${hash}_${format}_w${width}${height ? `_h${height}` : ""}_dpr${dpr}`;
// Add aggressive caching
c.header("Cache-Control", "public, max-age=315360000, immutable");
// c.header("Cache-Control", "public, max-age=315360000, immutable");
// Placeholder image response (to be replaced by real logic)
return c.text(`Would serve image: ${cacheKey}`);
return c.newResponse(await imageBytes.arrayBuffer(),
// {
// headers: {
// ...imageBytes.headers
// }
// }
);
return c.text("success")
}
)
}

View File

@@ -5,7 +5,7 @@ import { ImageRoute } from "./image";
const app = new Hono();
app
.use(logger(), async (c, next) => {
c.header("Cache-Control", "public, max-age=315360000, immutable");
// c.header("Cache-Control", "public, max-age=315360000, immutable");
return next();
})

View File

@@ -0,0 +1 @@
export const handler = () => { }