mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 08:45:38 +02:00
feat: Add image transforms
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
137
packages/functions/src/api/image.ts
Normal file
137
packages/functions/src/api/image.ts
Normal 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),
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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: () => { },
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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();
|
||||
})
|
||||
|
||||
|
||||
1
packages/functions/src/images/processor.ts
Normal file
1
packages/functions/src/images/processor.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const handler = () => { }
|
||||
Reference in New Issue
Block a user