feat: добавить базовые сервисы image-platform

- добавлены backend, admin, gateway и worker skeleton
- добавлены Drizzle schema, database package и initial migration
- добавлены shared packages для RabbitMQ topology и S3 helpers
- обновлены dev-инфраструктура, env example, scripts и dependencies
- обновлена документация под versioned image URLs и read-through flow
This commit is contained in:
2026-05-05 09:59:21 +03:00
parent 37592c8b81
commit bcadb85a83
66 changed files with 8698 additions and 213 deletions

20
apps/gateway/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "@image-platform/gateway",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"dev": "tsx watch src/main.ts",
"start": "node dist/main.js",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"fastify": "^5.8.5"
},
"devDependencies": {
"@types/node": "^25.6.0",
"tsx": "^4.21.0",
"typescript": "^6.0.3"
}
}

View File

@@ -0,0 +1,27 @@
export type GatewayConfig = {
backendUpstream: URL
host: string
port: number
}
export function loadGatewayConfig(): GatewayConfig {
return {
backendUpstream: new URL(process.env.GATEWAY_BACKEND_UPSTREAM ?? "http://localhost:3001"),
host: process.env.GATEWAY_HOST ?? "0.0.0.0",
port: parsePort(process.env.GATEWAY_PORT, 8888),
}
}
function parsePort(value: string | undefined, fallback: number) {
if (!value) {
return fallback
}
const parsed = Number.parseInt(value, 10)
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
throw new Error(`Invalid port: ${value}`)
}
return parsed
}

12
apps/gateway/src/main.ts Normal file
View File

@@ -0,0 +1,12 @@
import { loadGatewayConfig } from "./config.js"
import { createGatewayServer } from "./server.js"
const config = loadGatewayConfig()
const app = createGatewayServer(config)
try {
await app.listen({ host: config.host, port: config.port })
} catch (error) {
app.log.error(error)
process.exit(1)
}

77
apps/gateway/src/proxy.ts Normal file
View File

@@ -0,0 +1,77 @@
import { Readable } from "node:stream"
import type { FastifyReply, FastifyRequest } from "fastify"
const hopByHopHeaders = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
])
export async function proxyToUpstream(request: FastifyRequest, reply: FastifyReply, upstreamBaseUrl: URL) {
const upstreamUrl = new URL(request.url, upstreamBaseUrl)
const headers = buildProxyHeaders(request)
const init: RequestInit & { duplex?: "half" } = {
headers,
method: request.method,
redirect: "manual",
}
if (request.method !== "GET" && request.method !== "HEAD") {
init.body = request.raw as RequestInit["body"]
init.duplex = "half"
}
const response = await fetch(upstreamUrl, init)
reply.code(response.status)
response.headers.forEach((value, key) => {
if (!hopByHopHeaders.has(key.toLowerCase())) {
reply.header(key, value)
}
})
if (!response.body) {
return reply.send()
}
return reply.send(Readable.fromWeb(response.body))
}
function buildProxyHeaders(request: FastifyRequest) {
const headers = new Headers()
for (const [key, rawValue] of Object.entries(request.headers)) {
const lowerKey = key.toLowerCase()
if (lowerKey === "host" || hopByHopHeaders.has(lowerKey)) {
continue
}
if (Array.isArray(rawValue)) {
for (const value of rawValue) {
headers.append(key, value)
}
continue
}
if (typeof rawValue === "string") {
headers.set(key, rawValue)
}
}
headers.set("x-forwarded-host", request.headers.host ?? "")
headers.set("x-forwarded-proto", "http")
if (request.ip) {
headers.set("x-forwarded-for", request.ip)
}
return headers
}

View File

@@ -0,0 +1,90 @@
import Fastify from "fastify"
import type { GatewayConfig } from "./config.js"
import { proxyToUpstream } from "./proxy.js"
export function createGatewayServer(config: GatewayConfig) {
const app = Fastify({
logger: true,
})
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)
const quality = parseOptionalInteger(request.query.q)
const format = request.query.f ?? "auto"
return reply.code(501).header("cache-control", "no-store").send({
assetId: request.params.assetId,
format,
message: "image gateway read-through pipeline is not implemented yet",
preset: request.params.preset,
quality,
status: "not_implemented",
version,
width,
})
},
)
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
q?: string
w?: string
}
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
}
const parsed = Number.parseInt(value, 10)
return Number.isFinite(parsed) ? parsed : null
}

View File

@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false
}
}

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"declaration": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noUncheckedIndexedAccess": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"target": "ES2023",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}