mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 08:45:38 +02:00
✨ feat: Add api (#112)
Add the api route -> https://nexus.nestri.workers.dev/
This commit is contained in:
33
packages/api/.gitignore
vendored
Normal file
33
packages/api/.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
# prod
|
||||
dist/
|
||||
|
||||
# dev
|
||||
.yarn/
|
||||
!.yarn/releases
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
!.vscode/*.code-snippets
|
||||
.idea/workspace.xml
|
||||
.idea/usage.statistics.xml
|
||||
.idea/shelf
|
||||
|
||||
# deps
|
||||
node_modules/
|
||||
.wrangler
|
||||
|
||||
# env
|
||||
.env
|
||||
.env.production
|
||||
.dev.vars
|
||||
|
||||
# logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
12
packages/api/README.md
Normal file
12
packages/api/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Nexus
|
||||
|
||||
## Development
|
||||
|
||||
```
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
```
|
||||
npm run deploy
|
||||
```
|
||||
BIN
packages/api/assets/favicon.ico
Normal file
BIN
packages/api/assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
14
packages/api/assets/favicon.svg
Normal file
14
packages/api/assets/favicon.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="48.672001"
|
||||
height="36.804001"
|
||||
viewBox="0 0 12.8778 9.7377253"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="layer1">
|
||||
<path
|
||||
d="m 2.093439,1.7855532 h 8.690922 V 2.2639978 H 2.093439 Z m 0,2.8440874 h 8.690922 V 5.1080848 H 2.093439 Z m 0,2.8440866 h 8.690922 V 7.952172 H 2.093439 Z"
|
||||
style="font-size:12px;fill:#ff4f01;fill-opacity:1;fill-rule:evenodd;stroke:#ff4f01;stroke-width:1.66201;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 590 B |
23
packages/api/package.json
Normal file
23
packages/api/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@nestri/nexus",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"description": "Nestri's core",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev src/index.ts",
|
||||
"deploy": "wrangler deploy --minify src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cf-wasm/resvg": "^0.1.21",
|
||||
"@cloudflare/workers-types": "^4.20240529.0",
|
||||
"@jsquash/avif": "^1.3.0",
|
||||
"@jsquash/jpeg": "^1.4.0",
|
||||
"@jsquash/resize": "^2.0.0",
|
||||
"@nestri/cache": "*",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"wrangler": "^3.72.2"
|
||||
}
|
||||
}
|
||||
38
packages/api/src/image/avatar.ts
Normal file
38
packages/api/src/image/avatar.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Hono } from 'hono'
|
||||
import { Resvg } from "@cf-wasm/resvg";
|
||||
import { generateGradient, createAvatarSvg } from '../utils';
|
||||
import { kvCaches } from "@nestri/cache"
|
||||
|
||||
const cacheOptions = {
|
||||
key: "nexus",
|
||||
namespace: "avatar-cache"
|
||||
};
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
const middleware = kvCaches(cacheOptions);
|
||||
|
||||
app.get('/:id', middleware, async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const [name, fileType] = id.split('.');
|
||||
const size = parseInt(c.req.query("size") || "200");
|
||||
|
||||
const validImageTypes = ["png"] //['jpg', 'png', 'webp', 'avif'];
|
||||
if (!validImageTypes.includes(fileType)) {
|
||||
return c.text('Invalid image type', 400);
|
||||
}
|
||||
|
||||
const gradient = generateGradient(name || Math.random() + "");
|
||||
// console.log(`gradient: ${JSON.stringify(gradient)}`)
|
||||
const svg = createAvatarSvg(size, gradient, fileType);
|
||||
const resvg = await Resvg.create(svg.toString());
|
||||
const pngData = resvg.render()
|
||||
const pngBuffer = pngData.asPng()
|
||||
|
||||
return c.newResponse(pngBuffer, 200, {
|
||||
"Content-Type": `image/${fileType}`,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable'
|
||||
})
|
||||
})
|
||||
|
||||
export default app
|
||||
72
packages/api/src/image/banner.ts
Normal file
72
packages/api/src/image/banner.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Hono } from 'hono'
|
||||
import { kvCaches } from "@nestri/cache"
|
||||
import resize, { initResize } from '@jsquash/resize';
|
||||
import decodeJpeg, { init as initDecodeJpeg } from '@jsquash/jpeg/decode';
|
||||
import encodeAvif, { init as initEncodeAvif } from '@jsquash/avif/encode.js';
|
||||
import JPEG_DEC_WASM from "@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm";
|
||||
import RESIZE_WASM from "@jsquash/resize/lib/resize/pkg/squoosh_resize_bg.wasm";
|
||||
import AVIF_ENC_WASM from "@jsquash/avif/codec/enc/avif_enc.wasm";
|
||||
|
||||
const cacheOptions = {
|
||||
key: "nexus",
|
||||
namespace: "cover-cache"
|
||||
};
|
||||
|
||||
const middleware = kvCaches(cacheOptions);
|
||||
|
||||
const decodeImage = async (buffer: ArrayBuffer) => {
|
||||
await initDecodeJpeg(JPEG_DEC_WASM);
|
||||
return decodeJpeg(buffer);
|
||||
}
|
||||
|
||||
const resizeImage = async (image: { width: number; height: number }, width: number, height: number) => {
|
||||
await initResize(RESIZE_WASM);
|
||||
// Resize image with respect to aspect ratio
|
||||
const aspectRatio = image.width / image.height;
|
||||
const newWidth = width;
|
||||
const newHeight = width / aspectRatio;
|
||||
return resize(image, { width: newWidth, height: newHeight, fitMethod: "stretch" });
|
||||
}
|
||||
|
||||
const encodeImage = async (image: { width: number; height: number }, format: string) => {
|
||||
if (format === 'avif') {
|
||||
await initEncodeAvif(AVIF_ENC_WASM);
|
||||
return encodeAvif(image);
|
||||
}
|
||||
throw new Error(`Unsupported image format: ${format}`);
|
||||
}
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.notFound((c) => c.json({ message: 'Not Found', ok: false }, 404))
|
||||
|
||||
app.get('/:id', middleware, async (c) => {
|
||||
const [gameId, imageType] = c.req.param("id").split('.');
|
||||
const width = parseInt(c.req.query("width") || "460");
|
||||
//We don't even use this, but let us keep it for future use
|
||||
const height = parseInt(c.req.query("height") || "215");
|
||||
if (!gameId || !imageType) {
|
||||
return c.text("Invalid image parameters", 400)
|
||||
}
|
||||
//Support Avif only because of it's small size
|
||||
const validImageTypes = ["avif"] //['jpg', 'png', 'webp', 'avif'];
|
||||
if (!validImageTypes.includes(imageType)) {
|
||||
return c.text('Invalid image type', 400);
|
||||
}
|
||||
|
||||
const imageUrl = `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${gameId}/header.jpg`;
|
||||
const image = await fetch(imageUrl);
|
||||
if (!image.ok) {
|
||||
return c.text('Image not found', 404);
|
||||
}
|
||||
const imageBuffer = await image.arrayBuffer();
|
||||
const imageData = await decodeImage(imageBuffer);
|
||||
const resizedImage = await resizeImage(imageData, width, height);
|
||||
const resizedImageBuffer = await encodeImage(resizedImage, imageType);
|
||||
return c.newResponse(resizedImageBuffer, 200, {
|
||||
"Content-Type": `image/${imageType}`,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable'
|
||||
})
|
||||
})
|
||||
|
||||
export default app
|
||||
72
packages/api/src/image/cover.ts
Normal file
72
packages/api/src/image/cover.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Hono } from 'hono'
|
||||
import { kvCaches } from "@nestri/cache"
|
||||
import resize, { initResize } from '@jsquash/resize';
|
||||
import decodeJpeg, { init as initDecodeJpeg } from '@jsquash/jpeg/decode';
|
||||
import encodeAvif, { init as initEncodeAvif } from '@jsquash/avif/encode.js';
|
||||
import JPEG_DEC_WASM from "@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm";
|
||||
import RESIZE_WASM from "@jsquash/resize/lib/resize/pkg/squoosh_resize_bg.wasm";
|
||||
import AVIF_ENC_WASM from "@jsquash/avif/codec/enc/avif_enc.wasm";
|
||||
|
||||
const cacheOptions = {
|
||||
key: "nexus",
|
||||
namespace: "cover-cache"
|
||||
};
|
||||
|
||||
const middleware = kvCaches(cacheOptions);
|
||||
|
||||
const decodeImage = async (buffer: ArrayBuffer) => {
|
||||
await initDecodeJpeg(JPEG_DEC_WASM);
|
||||
return decodeJpeg(buffer);
|
||||
}
|
||||
|
||||
const resizeImage = async (image: { width: number; height: number }, width: number, height: number) => {
|
||||
await initResize(RESIZE_WASM);
|
||||
// Resize image with respect to aspect ratio
|
||||
const aspectRatio = image.width / image.height;
|
||||
const newWidth = width;
|
||||
const newHeight = width / aspectRatio;
|
||||
return resize(image, { width: newWidth, height: newHeight, fitMethod: "stretch" });
|
||||
}
|
||||
|
||||
const encodeImage = async (image: { width: number; height: number }, format: string) => {
|
||||
if (format === 'avif') {
|
||||
await initEncodeAvif(AVIF_ENC_WASM);
|
||||
return encodeAvif(image);
|
||||
}
|
||||
throw new Error(`Unsupported image format: ${format}`);
|
||||
}
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.notFound((c) => c.json({ message: 'Not Found', ok: false }, 404))
|
||||
|
||||
app.get('/:id', middleware, async (c) => {
|
||||
const [gameId, imageType] = c.req.param("id").split('.');
|
||||
const width = parseInt(c.req.query("width") || "600");
|
||||
//We don't even use this, but let us keep it for future use
|
||||
const height = parseInt(c.req.query("height") || "900");
|
||||
if (!gameId || !imageType) {
|
||||
return c.text("Invalid image parameters", 400)
|
||||
}
|
||||
//Support Avif only because of it's small size
|
||||
const validImageTypes = ["avif"] //['jpg', 'png', 'webp', 'avif'];
|
||||
if (!validImageTypes.includes(imageType)) {
|
||||
return c.text('Invalid image type', 400);
|
||||
}
|
||||
|
||||
const imageUrl = `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${gameId}/library_600x900_2x.jpg`;
|
||||
const image = await fetch(imageUrl);
|
||||
if (!image.ok) {
|
||||
return c.text('Image not found', 404);
|
||||
}
|
||||
const imageBuffer = await image.arrayBuffer();
|
||||
const imageData = await decodeImage(imageBuffer);
|
||||
const resizedImage = await resizeImage(imageData, width, height);
|
||||
const resizedImageBuffer = await encodeImage(resizedImage, imageType);
|
||||
return c.newResponse(resizedImageBuffer, 200, {
|
||||
"Content-Type": `image/${imageType}`,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable'
|
||||
})
|
||||
})
|
||||
|
||||
export default app
|
||||
39
packages/api/src/image/index.ts
Normal file
39
packages/api/src/image/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Hono } from 'hono'
|
||||
import { cors } from 'hono/cors';
|
||||
import Cover from './cover'
|
||||
import Avatar from './avatar'
|
||||
import Banner from './banner'
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.get('/', (c) => {
|
||||
return c.text('Hello There! 👋🏾')
|
||||
})
|
||||
|
||||
app.notFound((c) => c.json({ message: 'Not Found', ok: false }, 404))
|
||||
|
||||
app.use(
|
||||
'/*',
|
||||
cors({
|
||||
origin: (origin, c) => {
|
||||
const allowedOriginPatterns = [
|
||||
/^https:\/\/.*\.nestri\.pages\.dev$/,
|
||||
/^https:\/\/.*\.nestri\.io$/,
|
||||
/^http:\/\/localhost:\d+$/ // For local development
|
||||
];
|
||||
|
||||
return allowedOriginPatterns.some(pattern => pattern.test(origin))
|
||||
? origin
|
||||
: 'https://nexus.nestri.io'
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
app.route('/cover', Cover)
|
||||
|
||||
app.route('/avatar', Avatar)
|
||||
|
||||
app.route("/banner", Banner)
|
||||
|
||||
export default app
|
||||
18
packages/api/src/index.ts
Normal file
18
packages/api/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Hono } from 'hono'
|
||||
import Image from "./image"
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.get('/', (c) => {
|
||||
return c.text('Hello There! 👋🏾')
|
||||
})
|
||||
|
||||
app.get('/favicon.ico', (c) => {
|
||||
return c.newResponse(null, 302, {
|
||||
Location: 'https://nestri.pages.dev/favicon.svg'
|
||||
})
|
||||
})
|
||||
|
||||
app.route("/image", Image)
|
||||
|
||||
export default app
|
||||
4
packages/api/src/types/api.d.ts
vendored
Normal file
4
packages/api/src/types/api.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*wasm' {
|
||||
const content: any;
|
||||
export default content;
|
||||
}
|
||||
33
packages/api/src/utils/create-avatar.tsx
Normal file
33
packages/api/src/utils/create-avatar.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
export const createAvatarSvg = (size: number, gradient: { fromColor: string, toColor: string }, fileType?: string, text?: string) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor={gradient.fromColor} />
|
||||
<stop offset="100%" stopColor={gradient.toColor} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect fill="url(#gradient)" x="0" y="0" width={size} height={size} />
|
||||
{fileType === "svg" && text && (
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
alignmentBaseline="central"
|
||||
dominantBaseline="central"
|
||||
textAnchor="middle"
|
||||
fill="#fff"
|
||||
fontFamily="sans-serif"
|
||||
fontSize={(size * 0.9) / text.length}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
24
packages/api/src/utils/gradient.ts
Normal file
24
packages/api/src/utils/gradient.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import color from "tinycolor2";
|
||||
|
||||
function fnv1a(str: string) {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash ^= str.charCodeAt(i);
|
||||
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export function generateGradient(username: string) {
|
||||
const hash = fnv1a(username);
|
||||
const hue1 = hash % 360;
|
||||
const hue2 = (hash >> 16) % 360;
|
||||
|
||||
const c1 = color({ h: hue1, s: 0.8, l: 0.6 });
|
||||
const c2 = color({ h: hue2, s: 0.8, l: 0.5 });
|
||||
|
||||
return {
|
||||
fromColor: c1.toHexString(),
|
||||
toColor: c2.toHexString(),
|
||||
};
|
||||
}
|
||||
2
packages/api/src/utils/index.ts
Normal file
2
packages/api/src/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './gradient'
|
||||
export * from './create-avatar'
|
||||
17
packages/api/tsconfig.json
Normal file
17
packages/api/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": [
|
||||
"ESNext"
|
||||
],
|
||||
"types": [
|
||||
"@cloudflare/workers-types/2023-07-01"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx"
|
||||
},
|
||||
}
|
||||
26
packages/api/wrangler.toml
Normal file
26
packages/api/wrangler.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
name = "nexus"
|
||||
compatibility_date = "2024-08-14"
|
||||
send_metrics = false
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "nexus"
|
||||
id = "e21527f9f1ed4adbaca1fa23d7f147c9"
|
||||
|
||||
# [vars]
|
||||
# MY_VAR = "my-variable"
|
||||
|
||||
# [[kv_namespaces]]
|
||||
# binding = "MY_KV_NAMESPACE"
|
||||
# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
|
||||
# [[r2_buckets]]
|
||||
# binding = "MY_BUCKET"
|
||||
# bucket_name = "my-bucket"
|
||||
|
||||
# [[d1_databases]]
|
||||
# binding = "DB"
|
||||
# database_name = "my-database"
|
||||
# database_id = ""
|
||||
|
||||
# [ai]
|
||||
# binding = "AI"
|
||||
Reference in New Issue
Block a user