2026-05-05 13:25:28 +03:00
|
|
|
import Fastify, { type FastifyReply } from "fastify"
|
|
|
|
|
import {
|
|
|
|
|
ImageTransformConfigError,
|
|
|
|
|
normalizeImageTransform,
|
|
|
|
|
parseBooleanFlag,
|
|
|
|
|
selectFormatForAccept,
|
|
|
|
|
type ActualImageFormat,
|
|
|
|
|
type ResizeMode,
|
|
|
|
|
} from "@image-platform/image-config"
|
2026-05-05 09:59:21 +03:00
|
|
|
|
|
|
|
|
import type { GatewayConfig } from "./config.js"
|
2026-05-05 13:25:28 +03:00
|
|
|
import { ImageMemoryCache, type CachedImage } from "./image-cache.js"
|
2026-05-05 09:59:21 +03:00
|
|
|
import { proxyToUpstream } from "./proxy.js"
|
|
|
|
|
|
|
|
|
|
export function createGatewayServer(config: GatewayConfig) {
|
|
|
|
|
const app = Fastify({
|
|
|
|
|
logger: true,
|
|
|
|
|
})
|
2026-05-05 13:25:28 +03:00
|
|
|
const imageCache = new ImageMemoryCache(config.l1MaxEntries, config.l1TtlMs)
|
|
|
|
|
const allowCustomTransforms = parseBooleanFlag(process.env.IMAGE_ALLOW_CUSTOM_TRANSFORMS, false)
|
2026-05-05 09:59:21 +03:00
|
|
|
|
|
|
|
|
app.get("/health", async () => ({
|
|
|
|
|
service: "image-platform-gateway",
|
|
|
|
|
status: "ok",
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
app.get<{ Params: ImageParams; Querystring: ImageQuery }>(
|
|
|
|
|
"/images/:assetId/:version/:preset",
|
|
|
|
|
async (request, reply) => {
|
|
|
|
|
const version = parseVersionParam(request.params.version)
|
|
|
|
|
|
|
|
|
|
if (version === null) {
|
|
|
|
|
return reply.code(400).send({
|
|
|
|
|
message: "image version must use v{number} format",
|
|
|
|
|
statusCode: 400,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const width = parseOptionalInteger(request.query.w)
|
2026-05-05 13:25:28 +03:00
|
|
|
const height = parseOptionalNonNegativeInteger(request.query.h)
|
2026-05-05 09:59:21 +03:00
|
|
|
const quality = parseOptionalInteger(request.query.q)
|
2026-05-05 13:25:28 +03:00
|
|
|
const resize = parseResizeMode(request.query.fit)
|
2026-05-05 09:59:21 +03:00
|
|
|
|
2026-05-05 13:25:28 +03:00
|
|
|
if (
|
|
|
|
|
(request.query.w !== undefined && width === null) ||
|
|
|
|
|
(request.query.h !== undefined && height === null) ||
|
|
|
|
|
(request.query.q !== undefined && quality === null)
|
|
|
|
|
) {
|
|
|
|
|
return reply.code(400).send({
|
|
|
|
|
message: "w, h and q query params must be positive integers",
|
|
|
|
|
statusCode: 400,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (request.query.fit !== undefined && resize === null) {
|
|
|
|
|
return reply.code(400).send({
|
|
|
|
|
message: "fit query param must be fit or fill",
|
|
|
|
|
statusCode: 400,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const format = selectFormat({
|
|
|
|
|
acceptHeader: request.headers.accept,
|
|
|
|
|
allowCustomTransforms,
|
|
|
|
|
preset: request.params.preset,
|
|
|
|
|
requestedFormat: request.query.f ?? "auto",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!format.ok) {
|
|
|
|
|
return reply.code(400).send({
|
|
|
|
|
message: format.message,
|
|
|
|
|
statusCode: 400,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const transform = normalizeTransform({
|
|
|
|
|
allowCustomTransforms,
|
|
|
|
|
format: format.value.format,
|
|
|
|
|
height,
|
2026-05-05 09:59:21 +03:00
|
|
|
preset: request.params.preset,
|
|
|
|
|
quality,
|
2026-05-05 13:25:28 +03:00
|
|
|
requestedFormat: format.value.requestedFormat,
|
|
|
|
|
resize,
|
2026-05-05 09:59:21 +03:00
|
|
|
width,
|
|
|
|
|
})
|
2026-05-05 13:25:28 +03:00
|
|
|
|
|
|
|
|
if (!transform.ok) {
|
|
|
|
|
return reply.code(400).send({
|
|
|
|
|
message: transform.message,
|
|
|
|
|
statusCode: 400,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cacheKey = buildImageCacheKey({
|
|
|
|
|
assetId: request.params.assetId,
|
|
|
|
|
format: transform.value.format,
|
|
|
|
|
height: transform.value.height,
|
|
|
|
|
preset: transform.value.preset,
|
|
|
|
|
quality: transform.value.quality,
|
|
|
|
|
resize: transform.value.resize,
|
|
|
|
|
version,
|
|
|
|
|
width: transform.value.width,
|
|
|
|
|
})
|
|
|
|
|
const cached = imageCache.get(cacheKey)
|
|
|
|
|
const vary = transform.value.requestedFormat === "auto" ? "Accept" : null
|
|
|
|
|
|
|
|
|
|
if (cached) {
|
|
|
|
|
return sendImage(reply, cached, "HIT", vary)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const backendResponse = await fetch(new URL("/api/internal/images/ensure", config.backendUpstream), {
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
assetId: request.params.assetId,
|
|
|
|
|
format: transform.value.format,
|
|
|
|
|
height: transform.value.height,
|
|
|
|
|
preset: transform.value.preset,
|
|
|
|
|
quality: transform.value.quality,
|
|
|
|
|
requestedFormat: transform.value.requestedFormat,
|
|
|
|
|
resize: transform.value.resize,
|
|
|
|
|
version,
|
|
|
|
|
width: transform.value.width,
|
|
|
|
|
}),
|
|
|
|
|
headers: { "content-type": "application/json" },
|
|
|
|
|
method: "POST",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!backendResponse.ok) {
|
|
|
|
|
const body = await backendResponse.text()
|
|
|
|
|
|
|
|
|
|
return reply
|
|
|
|
|
.code(backendResponse.status)
|
|
|
|
|
.header("cache-control", "no-store")
|
|
|
|
|
.header("content-type", backendResponse.headers.get("content-type") ?? "text/plain; charset=utf-8")
|
|
|
|
|
.send(body)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const image: CachedImage = {
|
|
|
|
|
body: Buffer.from(await backendResponse.arrayBuffer()),
|
|
|
|
|
cacheControl: backendResponse.headers.get("cache-control") ?? "public, max-age=31536000, immutable",
|
|
|
|
|
contentType: backendResponse.headers.get("content-type") ?? contentTypeForFormat(transform.value.format),
|
|
|
|
|
etag: backendResponse.headers.get("etag"),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
imageCache.set(cacheKey, image)
|
|
|
|
|
|
|
|
|
|
return sendImage(reply, image, "MISS", vary)
|
2026-05-05 09:59:21 +03:00
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
app.all("/api/*", async (request, reply) => proxyToUpstream(request, reply, config.backendUpstream))
|
|
|
|
|
app.all("/docs", async (request, reply) => proxyToUpstream(request, reply, config.backendUpstream))
|
|
|
|
|
app.all("/docs/*", async (request, reply) => proxyToUpstream(request, reply, config.backendUpstream))
|
|
|
|
|
app.all("/docs-json", async (request, reply) => proxyToUpstream(request, reply, config.backendUpstream))
|
|
|
|
|
|
|
|
|
|
app.setNotFoundHandler(async (_request, reply) => {
|
|
|
|
|
return reply.code(404).send({
|
|
|
|
|
message: "route not found",
|
|
|
|
|
statusCode: 404,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return app
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ImageQuery = {
|
|
|
|
|
f?: string
|
2026-05-05 13:25:28 +03:00
|
|
|
fit?: string
|
|
|
|
|
h?: string
|
2026-05-05 09:59:21 +03:00
|
|
|
q?: string
|
|
|
|
|
w?: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 13:25:28 +03:00
|
|
|
type ImageCacheKeyInput = {
|
|
|
|
|
assetId: string
|
|
|
|
|
format: ActualImageFormat
|
|
|
|
|
height: number
|
|
|
|
|
preset: string
|
|
|
|
|
quality: number
|
|
|
|
|
resize: ResizeMode
|
|
|
|
|
version: number
|
|
|
|
|
width: number
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 09:59:21 +03:00
|
|
|
type ImageParams = {
|
|
|
|
|
assetId: string
|
|
|
|
|
preset: string
|
|
|
|
|
version: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseVersionParam(value: string) {
|
|
|
|
|
if (!value.startsWith("v")) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parsed = Number.parseInt(value.slice(1), 10)
|
|
|
|
|
|
|
|
|
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseOptionalInteger(value: string | undefined) {
|
|
|
|
|
if (!value) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 13:25:28 +03:00
|
|
|
if (!/^\d+$/.test(value)) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 09:59:21 +03:00
|
|
|
const parsed = Number.parseInt(value, 10)
|
|
|
|
|
|
2026-05-05 13:25:28 +03:00
|
|
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseOptionalNonNegativeInteger(value: string | undefined) {
|
|
|
|
|
if (!value) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!/^\d+$/.test(value)) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parsed = Number.parseInt(value, 10)
|
|
|
|
|
|
|
|
|
|
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseResizeMode(value: string | undefined): ResizeMode | null {
|
|
|
|
|
if (value === undefined) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return value === "fit" || value === "fill" ? value : null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function selectFormat(input: Parameters<typeof selectFormatForAccept>[0]) {
|
|
|
|
|
try {
|
|
|
|
|
return { ok: true as const, value: selectFormatForAccept(input) }
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error instanceof ImageTransformConfigError) {
|
|
|
|
|
return { message: error.message, ok: false as const }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildImageCacheKey(input: ImageCacheKeyInput) {
|
|
|
|
|
return [
|
|
|
|
|
input.assetId,
|
|
|
|
|
input.version,
|
|
|
|
|
input.preset,
|
|
|
|
|
input.width,
|
|
|
|
|
input.height,
|
|
|
|
|
input.resize,
|
|
|
|
|
input.quality,
|
|
|
|
|
input.format,
|
|
|
|
|
].join(":")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeTransform(input: Parameters<typeof normalizeImageTransform>[0]) {
|
|
|
|
|
try {
|
|
|
|
|
return { ok: true as const, value: normalizeImageTransform(input) }
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error instanceof ImageTransformConfigError) {
|
|
|
|
|
return { message: error.message, ok: false as const }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sendImage(reply: FastifyReply, image: CachedImage, cacheStatus: string, vary: string | null) {
|
|
|
|
|
if (image.etag) {
|
|
|
|
|
reply.header("etag", image.etag)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (vary) {
|
|
|
|
|
reply.header("vary", vary)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return reply
|
|
|
|
|
.code(200)
|
|
|
|
|
.header("cache-control", image.cacheControl)
|
|
|
|
|
.header("content-length", image.body.length.toString())
|
|
|
|
|
.header("content-type", image.contentType)
|
|
|
|
|
.header("x-image-platform-l1", cacheStatus)
|
|
|
|
|
.send(image.body)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function contentTypeForFormat(format: ActualImageFormat) {
|
|
|
|
|
if (format === "jpg") {
|
|
|
|
|
return "image/jpeg"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `image/${format}`
|
2026-05-05 09:59:21 +03:00
|
|
|
}
|