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:
20
apps/gateway/package.json
Normal file
20
apps/gateway/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
27
apps/gateway/src/config.ts
Normal file
27
apps/gateway/src/config.ts
Normal 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
12
apps/gateway/src/main.ts
Normal 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
77
apps/gateway/src/proxy.ts
Normal 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
|
||||
}
|
||||
90
apps/gateway/src/server.ts
Normal file
90
apps/gateway/src/server.ts
Normal 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
|
||||
}
|
||||
6
apps/gateway/tsconfig.build.json
Normal file
6
apps/gateway/tsconfig.build.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false
|
||||
}
|
||||
}
|
||||
22
apps/gateway/tsconfig.json
Normal file
22
apps/gateway/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user