mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 16:55:37 +02:00
⭐ feat(infra): Migrate to serverless Lambda architecture (#291)
## Description <!-- Briefly describe the purpose and scope of your changes --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced serverless API and authentication endpoints, improving scalability and reliability. - Added rate limiting to the API, providing protection against excessive requests and returning custom error responses. - **Improvements** - Simplified infrastructure for both API and authentication, reducing complexity and improving maintainability. - Updated resource allocations for backend services to optimize performance and cost. - **Bug Fixes** - Removed unused scripts and configuration, resulting in a cleaner development environment. - **Other** - Updated type declarations to reflect new infrastructure changes. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
135
infra/api.ts
135
infra/api.ts
@@ -1,14 +1,14 @@
|
|||||||
import { bus } from "./bus";
|
import { bus } from "./bus";
|
||||||
|
import { vpc } from "./vpc";
|
||||||
import { auth } from "./auth";
|
import { auth } from "./auth";
|
||||||
import { domain } from "./dns";
|
import { domain } from "./dns";
|
||||||
import { secret } from "./secret";
|
import { secret } from "./secret";
|
||||||
import { cluster } from "./cluster";
|
|
||||||
import { postgres } from "./postgres";
|
import { postgres } from "./postgres";
|
||||||
|
|
||||||
export const apiService = new sst.aws.Service("Api", {
|
const apiFn = new sst.aws.Function("ApiFn", {
|
||||||
cluster,
|
vpc,
|
||||||
cpu: $app.stage === "production" ? "2 vCPU" : undefined,
|
handler: "packages/functions/src/api/index.handler",
|
||||||
memory: $app.stage === "production" ? "4 GB" : undefined,
|
streaming: !$dev,
|
||||||
link: [
|
link: [
|
||||||
bus,
|
bus,
|
||||||
auth,
|
auth,
|
||||||
@@ -22,73 +22,90 @@ export const apiService = new sst.aws.Service("Api", {
|
|||||||
secret.NestriProMonthly,
|
secret.NestriProMonthly,
|
||||||
secret.NestriProYearly,
|
secret.NestriProYearly,
|
||||||
],
|
],
|
||||||
command: ["bun", "run", "./src/api/index.ts"],
|
url: true,
|
||||||
image: {
|
});
|
||||||
dockerfile: "packages/functions/Containerfile",
|
|
||||||
|
const provider = new aws.Provider("UsEast1", { region: "us-east-1" });
|
||||||
|
|
||||||
|
const webAcl = new aws.wafv2.WebAcl(
|
||||||
|
"ApiWaf",
|
||||||
|
{
|
||||||
|
scope: "CLOUDFRONT",
|
||||||
|
defaultAction: {
|
||||||
|
allow: {},
|
||||||
|
},
|
||||||
|
visibilityConfig: {
|
||||||
|
cloudwatchMetricsEnabled: true,
|
||||||
|
metricName: "api-rate-limit-metric",
|
||||||
|
sampledRequestsEnabled: true,
|
||||||
},
|
},
|
||||||
loadBalancer: {
|
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
listen: "80/http",
|
name: "rate-limit-rule",
|
||||||
forward: "3001/http",
|
priority: 1,
|
||||||
|
action: {
|
||||||
|
block: {
|
||||||
|
customResponse: {
|
||||||
|
responseCode: 429,
|
||||||
|
customResponseBodyKey: "rate-limit-response",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
statement: {
|
||||||
|
rateBasedStatement: {
|
||||||
|
limit: 2 * 60, // 2 rps per authorization header
|
||||||
|
evaluationWindowSec: 60,
|
||||||
|
aggregateKeyType: "CUSTOM_KEYS",
|
||||||
|
customKeys: [
|
||||||
|
{
|
||||||
|
header: {
|
||||||
|
name: "Authorization",
|
||||||
|
textTransformations: [{ priority: 0, type: "NONE" }],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
dev: {
|
|
||||||
url: "http://localhost:3001",
|
|
||||||
command: "bun dev:api",
|
|
||||||
directory: "packages/functions",
|
|
||||||
},
|
},
|
||||||
scaling:
|
visibilityConfig: {
|
||||||
$app.stage === "production"
|
cloudwatchMetricsEnabled: true,
|
||||||
? {
|
metricName: "rate-limit-rule-metric",
|
||||||
min: 2,
|
sampledRequestsEnabled: true,
|
||||||
max: 10,
|
},
|
||||||
}
|
},
|
||||||
: undefined,
|
],
|
||||||
// For persisting actor state
|
customResponseBodies: [
|
||||||
transform: {
|
|
||||||
taskDefinition: (args) => {
|
|
||||||
const volumes = $output(args.volumes).apply(v => {
|
|
||||||
const next = [...v, {
|
|
||||||
name: "shared-tmp",
|
|
||||||
dockerVolumeConfiguration: {
|
|
||||||
scope: "shared",
|
|
||||||
driver: "local"
|
|
||||||
}
|
|
||||||
}];
|
|
||||||
|
|
||||||
return next;
|
|
||||||
})
|
|
||||||
|
|
||||||
// "containerDefinitions" is a JSON string, parse first
|
|
||||||
let containers = $jsonParse(args.containerDefinitions);
|
|
||||||
|
|
||||||
containers = containers.apply((containerDefinitions) => {
|
|
||||||
containerDefinitions[0].mountPoints = [
|
|
||||||
...(containerDefinitions[0].mountPoints ?? []),
|
|
||||||
{
|
{
|
||||||
sourceVolume: "shared-tmp",
|
key: "rate-limit-response",
|
||||||
containerPath: "/tmp"
|
content: JSON.stringify({
|
||||||
|
type: "rate_limit",
|
||||||
|
code: "too_many_requests",
|
||||||
|
message: "Rate limit exceeded. Please try again later.",
|
||||||
|
}),
|
||||||
|
contentType: "APPLICATION_JSON",
|
||||||
},
|
},
|
||||||
]
|
],
|
||||||
return containerDefinitions;
|
},
|
||||||
});
|
{ provider },
|
||||||
|
);
|
||||||
|
|
||||||
args.volumes = volumes
|
export const api = new sst.aws.Router("Api", {
|
||||||
args.containerDefinitions = $jsonStringify(containers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
export const api = !$dev ? new sst.aws.Router("ApiRoute", {
|
|
||||||
routes: {
|
routes: {
|
||||||
// I think api.url should work all the same
|
"/*": apiFn.url,
|
||||||
"/*": apiService.nodes.loadBalancer.dnsName,
|
|
||||||
},
|
},
|
||||||
domain: {
|
domain: {
|
||||||
name: "api." + domain,
|
name: "api." + domain,
|
||||||
dns: sst.cloudflare.dns(),
|
dns: sst.cloudflare.dns(),
|
||||||
},
|
},
|
||||||
}) : apiService
|
transform: {
|
||||||
|
cdn(args) {
|
||||||
|
if (!args.transform) {
|
||||||
|
args.transform = {
|
||||||
|
distribution: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
args.transform!.distribution = {
|
||||||
|
webAclId: webAcl.arn,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
import { bus } from "./bus";
|
import { bus } from "./bus";
|
||||||
|
import { vpc } from "./vpc";
|
||||||
import { domain } from "./dns";
|
import { domain } from "./dns";
|
||||||
import { secret } from "./secret";
|
import { secret } from "./secret";
|
||||||
import { cluster } from "./cluster";
|
|
||||||
import { postgres } from "./postgres";
|
import { postgres } from "./postgres";
|
||||||
|
|
||||||
export const authService = new sst.aws.Service("Auth", {
|
export const auth = new sst.aws.Auth("Auth", {
|
||||||
cluster,
|
authorizer: {
|
||||||
cpu: $app.stage === "production" ? "1 vCPU" : undefined,
|
vpc,
|
||||||
memory: $app.stage === "production" ? "2 GB" : undefined,
|
|
||||||
command: ["bun", "run", "./src/auth/index.ts"],
|
|
||||||
link: [
|
link: [
|
||||||
bus,
|
bus,
|
||||||
postgres,
|
postgres,
|
||||||
@@ -18,81 +16,17 @@ export const authService = new sst.aws.Service("Auth", {
|
|||||||
secret.GithubClientSecret,
|
secret.GithubClientSecret,
|
||||||
secret.DiscordClientSecret,
|
secret.DiscordClientSecret,
|
||||||
],
|
],
|
||||||
image: {
|
|
||||||
dockerfile: "packages/functions/Containerfile",
|
|
||||||
},
|
|
||||||
environment: {
|
|
||||||
NO_COLOR: "1",
|
|
||||||
STORAGE: "/tmp/persist.json"
|
|
||||||
},
|
|
||||||
loadBalancer: {
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
listen: "80/http",
|
|
||||||
forward: "3002/http",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
actions: ["ses:SendEmail"],
|
actions: ["ses:SendEmail"],
|
||||||
resources: ["*"],
|
resources: ["*"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
dev: {
|
handler: "packages/functions/src/auth/index.handler",
|
||||||
command: "bun dev:auth",
|
|
||||||
directory: "packages/functions",
|
|
||||||
url: "http://localhost:3002",
|
|
||||||
},
|
|
||||||
scaling:
|
|
||||||
$app.stage === "production"
|
|
||||||
? {
|
|
||||||
min: 2,
|
|
||||||
max: 10,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
//For temporarily persisting the persist.json
|
|
||||||
transform: {
|
|
||||||
taskDefinition: (args) => {
|
|
||||||
const volumes = $output(args.volumes).apply(v => {
|
|
||||||
const next = [...v, {
|
|
||||||
name: "shared-tmp",
|
|
||||||
dockerVolumeConfiguration: {
|
|
||||||
scope: "shared",
|
|
||||||
driver: "local"
|
|
||||||
}
|
|
||||||
}];
|
|
||||||
|
|
||||||
return next;
|
|
||||||
})
|
|
||||||
|
|
||||||
// "containerDefinitions" is a JSON string, parse first
|
|
||||||
let containers = $jsonParse(args.containerDefinitions);
|
|
||||||
|
|
||||||
containers = containers.apply((containerDefinitions) => {
|
|
||||||
containerDefinitions[0].mountPoints = [
|
|
||||||
...(containerDefinitions[0].mountPoints ?? []),
|
|
||||||
{
|
|
||||||
sourceVolume: "shared-tmp",
|
|
||||||
containerPath: "/tmp"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
return containerDefinitions;
|
|
||||||
});
|
|
||||||
|
|
||||||
args.volumes = volumes
|
|
||||||
args.containerDefinitions = $jsonStringify(containers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const auth = !$dev ? new sst.aws.Router("AuthRoute", {
|
|
||||||
routes: {
|
|
||||||
// I think auth.url should work all the same
|
|
||||||
"/*": authService.nodes.loadBalancer.dnsName,
|
|
||||||
},
|
},
|
||||||
domain: {
|
domain: {
|
||||||
name: "auth." + domain,
|
name: "auth." + domain,
|
||||||
dns: sst.cloudflare.dns(),
|
dns: sst.cloudflare.dns(),
|
||||||
},
|
},
|
||||||
}) : authService
|
forceUpgrade: "v2",
|
||||||
|
});
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { auth } from "./auth";
|
// import { auth } from "./auth";
|
||||||
import { postgres } from "./postgres";
|
import { postgres } from "./postgres";
|
||||||
|
|
||||||
export const device = new sst.aws.Realtime("Realtime", {
|
export const device = new sst.aws.Realtime("Realtime", {
|
||||||
authorizer: {
|
authorizer: {
|
||||||
link: [auth, postgres],
|
link: [ postgres],
|
||||||
handler: "packages/functions/src/realtime/authorizer.handler"
|
handler: "packages/functions/src/realtime/authorizer.handler"
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { vpc } from "./vpc";
|
|
||||||
import { auth } from "./auth";
|
import { auth } from "./auth";
|
||||||
import { domain } from "./dns";
|
import { domain } from "./dns";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
@@ -6,7 +5,6 @@ import { cluster } from "./cluster";
|
|||||||
import { storage } from "./storage";
|
import { storage } from "./storage";
|
||||||
import { postgres } from "./postgres";
|
import { postgres } from "./postgres";
|
||||||
|
|
||||||
// const connectionString = $interpolate`postgresql://${postgres.username}:${postgres.password}@${postgres.host}/${postgres.database}`
|
|
||||||
const connectionString = $interpolate`postgresql://${postgres.username}:${postgres.password}@${postgres.host}:${postgres.port}/${postgres.database}`;
|
const connectionString = $interpolate`postgresql://${postgres.username}:${postgres.password}@${postgres.host}:${postgres.port}/${postgres.database}`;
|
||||||
|
|
||||||
const tag = $dev
|
const tag = $dev
|
||||||
@@ -43,12 +41,9 @@ const replicationManager = !$dev
|
|||||||
? new sst.aws.Service(`ZeroReplication`, {
|
? new sst.aws.Service(`ZeroReplication`, {
|
||||||
cluster,
|
cluster,
|
||||||
wait: true,
|
wait: true,
|
||||||
...($app.stage === "production"
|
cpu: "0.5 vCPU",
|
||||||
? {
|
memory: "1 GB",
|
||||||
cpu: "2 vCPU",
|
capacity: "spot",
|
||||||
memory: "4 GB",
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
architecture: "arm64",
|
architecture: "arm64",
|
||||||
image: zeroEnv.ZERO_IMAGE_URL,
|
image: zeroEnv.ZERO_IMAGE_URL,
|
||||||
link: [storage, postgres],
|
link: [storage, postgres],
|
||||||
@@ -130,15 +125,9 @@ export const zero = new sst.aws.Service("Zero", {
|
|||||||
image: zeroEnv.ZERO_IMAGE_URL,
|
image: zeroEnv.ZERO_IMAGE_URL,
|
||||||
link: [storage, postgres],
|
link: [storage, postgres],
|
||||||
architecture: "arm64",
|
architecture: "arm64",
|
||||||
...($app.stage === "production"
|
cpu: "0.5 vCPU",
|
||||||
? {
|
memory: "1 GB",
|
||||||
cpu: "2 vCPU",
|
capacity: "spot",
|
||||||
memory: "4 GB",
|
|
||||||
capacity: "spot"
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
capacity: "spot"
|
|
||||||
}),
|
|
||||||
environment: {
|
environment: {
|
||||||
...zeroEnv,
|
...zeroEnv,
|
||||||
...($dev
|
...($dev
|
||||||
|
|||||||
@@ -7,10 +7,6 @@
|
|||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/steamcommunity": "^3.43.8"
|
"@types/steamcommunity": "^3.43.8"
|
||||||
},
|
},
|
||||||
"scripts": {
|
|
||||||
"dev:auth": "bun run --watch ./src/auth/index.ts",
|
|
||||||
"dev:api": "bun run --watch ./src/api/index.ts"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import "zod-openapi/extend";
|
import "zod-openapi/extend";
|
||||||
|
import { Hono } from "hono";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { GameApi } from "./game";
|
import { GameApi } from "./game";
|
||||||
import { SteamApi } from "./steam";
|
import { SteamApi } from "./steam";
|
||||||
import { auth } from "./utils/auth";
|
import { auth } from "./utils/auth";
|
||||||
import { FriendApi } from "./friend";
|
import { FriendApi } from "./friend";
|
||||||
import { logger } from "hono/logger";
|
import { logger } from "hono/logger";
|
||||||
import { type Env, Hono } from "hono";
|
|
||||||
import { Realtime } from "./realtime";
|
|
||||||
import { AccountApi } from "./account";
|
import { AccountApi } from "./account";
|
||||||
import { openAPISpecs } from "hono-openapi";
|
import { openAPISpecs } from "hono-openapi";
|
||||||
import { patchLogger } from "../utils/patch-logger";
|
import { patchLogger } from "../utils/patch-logger";
|
||||||
import { HTTPException } from "hono/http-exception";
|
import { HTTPException } from "hono/http-exception";
|
||||||
|
import { handle, streamHandle } from "hono/aws-lambda";
|
||||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||||
|
|
||||||
patchLogger();
|
patchLogger();
|
||||||
@@ -29,7 +29,6 @@ const routes = app
|
|||||||
.get("/", (c) => c.text("Hello World!"))
|
.get("/", (c) => c.text("Hello World!"))
|
||||||
.route("/games", GameApi.route)
|
.route("/games", GameApi.route)
|
||||||
.route("/steam", SteamApi.route)
|
.route("/steam", SteamApi.route)
|
||||||
.route("/realtime", Realtime.route)
|
|
||||||
.route("/friends", FriendApi.route)
|
.route("/friends", FriendApi.route)
|
||||||
.route("/account", AccountApi.route)
|
.route("/account", AccountApi.route)
|
||||||
.onError((error, c) => {
|
.onError((error, c) => {
|
||||||
@@ -94,13 +93,6 @@ app.get(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export default {
|
export type Routes = typeof routes;
|
||||||
port: 3001,
|
|
||||||
idleTimeout: 255,
|
export const handler = process.env.SST_LIVE ? handle(app) : streamHandle(app);
|
||||||
webSocketHandler: Realtime.webSocketHandler,
|
|
||||||
fetch: (req: Request,env: Env) =>
|
|
||||||
app.fetch(req, env, {
|
|
||||||
waitUntil: (fn) => fn,
|
|
||||||
passThroughOnException: () => { },
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
@@ -1,24 +1,19 @@
|
|||||||
import { Resource } from "sst";
|
import { Resource } from "sst";
|
||||||
import { type Env } from "hono";
|
|
||||||
import { logger } from "hono/logger";
|
import { logger } from "hono/logger";
|
||||||
import { subjects } from "../subjects";
|
import { subjects } from "../subjects";
|
||||||
|
import { handle } from "hono/aws-lambda";
|
||||||
import { PasswordUI, Select } from "./ui";
|
import { PasswordUI, Select } from "./ui";
|
||||||
import { issuer } from "@openauthjs/openauth";
|
import { issuer } from "@openauthjs/openauth";
|
||||||
import { User } from "@nestri/core/user/index";
|
import { User } from "@nestri/core/user/index";
|
||||||
import { Email } from "@nestri/core/email/index";
|
import { Email } from "@nestri/core/email/index";
|
||||||
import { patchLogger } from "../utils/patch-logger";
|
import { patchLogger } from "../utils/patch-logger";
|
||||||
import { handleDiscord, handleGithub } from "./utils";
|
import { handleDiscord, handleGithub } from "./utils";
|
||||||
import { MemoryStorage } from "@openauthjs/openauth/storage/memory";
|
|
||||||
import { DiscordAdapter, PasswordAdapter, GithubAdapter } from "./adapters";
|
import { DiscordAdapter, PasswordAdapter, GithubAdapter } from "./adapters";
|
||||||
|
|
||||||
patchLogger();
|
patchLogger();
|
||||||
|
|
||||||
const app = issuer({
|
const app = issuer({
|
||||||
//TODO: Create our own Storage (?)
|
|
||||||
select: Select(),
|
select: Select(),
|
||||||
storage: MemoryStorage({
|
|
||||||
persist: process.env.STORAGE
|
|
||||||
}),
|
|
||||||
theme: {
|
theme: {
|
||||||
title: "Nestri | Auth",
|
title: "Nestri | Auth",
|
||||||
primary: "#FF4F01",
|
primary: "#FF4F01",
|
||||||
@@ -161,13 +156,4 @@ const app = issuer({
|
|||||||
},
|
},
|
||||||
}).use(logger())
|
}).use(logger())
|
||||||
|
|
||||||
|
export const handler = handle(app);
|
||||||
export default {
|
|
||||||
port: 3002,
|
|
||||||
idleTimeout: 255,
|
|
||||||
fetch: (req: Request, env: Env) =>
|
|
||||||
app.fetch(req, env, {
|
|
||||||
waitUntil: (fn) => fn,
|
|
||||||
passThroughOnException: () => { },
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
11
sst-env.d.ts
vendored
11
sst-env.d.ts
vendored
@@ -7,13 +7,16 @@ import "sst"
|
|||||||
declare module "sst" {
|
declare module "sst" {
|
||||||
export interface Resource {
|
export interface Resource {
|
||||||
"Api": {
|
"Api": {
|
||||||
"service": string
|
"type": "sst.aws.Router"
|
||||||
"type": "sst.aws.Service"
|
"url": string
|
||||||
|
}
|
||||||
|
"ApiFn": {
|
||||||
|
"name": string
|
||||||
|
"type": "sst.aws.Function"
|
||||||
"url": string
|
"url": string
|
||||||
}
|
}
|
||||||
"Auth": {
|
"Auth": {
|
||||||
"service": string
|
"type": "sst.aws.Auth"
|
||||||
"type": "sst.aws.Service"
|
|
||||||
"url": string
|
"url": string
|
||||||
}
|
}
|
||||||
"Bus": {
|
"Bus": {
|
||||||
|
|||||||
Reference in New Issue
Block a user