Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
node_modules
.git
.gitignore
*.log
.env.local
.env
.env.development.local
.env.test.local
.env.production.local
coverage
dist
out
.vscode
.idea
*.tgz
bun.lockb
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,10 @@ DODO_PAYMENTS_WEBHOOK_SECRET= # Webhook signing secret

TEST_API_KEY=
REDIS_URL=
APP_URL="http://localhost:8070"
APP_URL="http://localhost:8070"

# gRPC TLS (optional)
GRPC_TLS_ENABLED=false
GRPC_TLS_CERT_PATH=
GRPC_TLS_KEY_PATH=
GRPC_TLS_CA_PATH=
12 changes: 12 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
FROM oven/bun:1

WORKDIR /app

COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile

COPY . .

EXPOSE 8070 8069

CMD ["bun", "run", "src/server.ts"]
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,20 @@ bun start

The server will start on `http://localhost:8070`

## TLS Configuration (gRPC)

By default, the gRPC server runs without TLS. In production, put the backend behind a TLS-terminating proxy or enable TLS directly.

To enable gRPC TLS, set:

```env
GRPC_TLS_ENABLED=true
GRPC_TLS_CERT_PATH="/path/to/server.crt"
GRPC_TLS_KEY_PATH="/path/to/server.key"
# Optional
GRPC_TLS_CA_PATH="/path/to/ca.pem"
```

## Endpoints

- **Connect / gRPC-Web / gRPC (h2c / HTTP/2 cleartext)**: `http://localhost:8069` (raw gRPC)
Expand Down
64 changes: 63 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

51 changes: 51 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
services:
db:
image: postgres:15
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: scrawn
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5

redis:
image: redis:7-alpine
restart: always
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5

server:
build:
context: .
dockerfile: Dockerfile
ports:
- "8070:8070"
- "8069:8069"
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
environment:
DATABASE_URL: postgresql://postgres:postgres@db:5432/scrawn
REDIS_URL: redis://redis:6379
NODE_ENV: development
volumes:
- .:/app
- /app/node_modules

volumes:
pgdata:
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"proto:push": "cd proto && git add . && git commit -s -m \"$1\" && git push && cd .."
},
"devDependencies": {
"@types/bun": "latest",
"@types/bun": "^1.3.13",
"@types/expr-eval": "^1.1.2",
"@types/luxon": "^3.7.1",
"@vitest/ui": "^4.0.3",
Expand Down Expand Up @@ -45,6 +45,7 @@
"luxon": "^3.7.2",
"pino": "^10.1.0",
"postgres": "^3.4.7",
"zod": "^4.1.12"
"zod": "^4.1.12",
"@grpc/grpc-js": "^1.8.14"
}
}
2 changes: 1 addition & 1 deletion proto
Submodule proto updated 1 files
+2 −4 buf.gen.yaml
7 changes: 7 additions & 0 deletions src/errors/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@ class WideEventLogger {
this.pino.info({ ...context, lifecycle: true }, message);
}

/**
* Log lifecycle warnings (startup, shutdown) without emitting request logs.
*/
lifecycleWarning(message: string, context?: Record<string, unknown>): void {
this.pino.warn({ ...context, lifecycle: true }, message);
}

/**
* Log fatal errors that prevent the server from operating.
*/
Expand Down
42 changes: 40 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { getPostgresDB } from "./storage/db/postgres/db.ts";
import { logger } from "./errors/logger.ts";
import { startRawGrpcServer } from "./servers/rawGrpcServer.ts";
import { startRawGrpcServer, type GrpcTlsOptions } from "./servers/rawGrpcServer.ts";
import { startFastifyServer } from "./servers/fastifyServer.ts";
import { OnboardingWorker } from "./workers/onboarding.ts";
import { getRedisConnection } from "./storage/db/redis.ts";
import { readFileSync } from "node:fs";
import * as Sentry from "@sentry/bun";

const isProduction = process.env.NODE_ENV === "production";
Expand Down Expand Up @@ -56,13 +57,50 @@ getRedisConnection(REDIS_URL);

const PORT = Number(process.env.PORT ?? 8070);
const GRPC_PORT = Number(process.env.GRPC_PORT ?? 8069);
const GRPC_TLS_CERT_PATH = process.env.GRPC_TLS_CERT_PATH;
const GRPC_TLS_KEY_PATH = process.env.GRPC_TLS_KEY_PATH;
const GRPC_TLS_CA_PATH = process.env.GRPC_TLS_CA_PATH;
const GRPC_TLS_ENABLED = process.env.GRPC_TLS_ENABLED === "true";

function loadGrpcTlsOptions(): GrpcTlsOptions | undefined {
if (!GRPC_TLS_ENABLED) {
return undefined;
}

if (!GRPC_TLS_CERT_PATH || !GRPC_TLS_KEY_PATH) {
logger.fatal("GRPC_TLS_ENABLED requires GRPC_TLS_CERT_PATH and GRPC_TLS_KEY_PATH");
throw new Error("gRPC TLS config incomplete");
}

const cert = readFileSync(GRPC_TLS_CERT_PATH);
const key = readFileSync(GRPC_TLS_KEY_PATH);
if (!cert.length || !key.length) {
logger.fatal("gRPC TLS cert or key file is empty");
throw new Error("gRPC TLS cert or key file is empty");
}

const ca = GRPC_TLS_CA_PATH ? readFileSync(GRPC_TLS_CA_PATH) : undefined;

return {
cert,
key,
ca,
};
}

let onboardingWorker: OnboardingWorker | undefined;

async function main(): Promise<void> {
startRawGrpcServer(GRPC_PORT);
const tlsOptions = loadGrpcTlsOptions();
startRawGrpcServer(GRPC_PORT, tlsOptions);
await startFastifyServer(PORT, GRPC_PORT);

if (!tlsOptions) {
logger.lifecycleWarning(
"Server running without TLS. In production, use a TLS-terminating proxy or enable TLS."
);
}

onboardingWorker = new OnboardingWorker();
logger.lifecycle("Onboarding worker started");
}
Expand Down
41 changes: 28 additions & 13 deletions src/servers/rawGrpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ import { logger } from "../errors/logger";
import { authInterceptor, type GrpcHandler, type GrpcUntypedHandler } from "../interceptors/auth";
import { loggingInterceptor } from "../interceptors/logging";

export function startRawGrpcServer(grpcPort: number): void {
export interface GrpcTlsOptions {
cert: Buffer;
key: Buffer;
ca?: Buffer;
}

export function startRawGrpcServer(grpcPort: number, tlsOptions?: GrpcTlsOptions): void {
const server = new grpc.Server();

// Wrap handlers with interceptors - cast to GrpcUntypedHandler to accept flexible call types
Expand Down Expand Up @@ -50,17 +56,26 @@ export function startRawGrpcServer(grpcPort: number): void {
createCheckoutLink: wrappedCreateCheckoutLink,
});

server.bindAsync(
`0.0.0.0:${grpcPort}`,
grpc.ServerCredentials.createInsecure(),
(error, port) => {
if (error) {
logger.fatal("Failed to start gRPC server", error as Error);
throw error;
}
logger.lifecycle("gRPC server listening", {
url: `0.0.0.0:${port}`,
});
const credentials = tlsOptions
? grpc.ServerCredentials.createSsl(
tlsOptions.ca ?? null,
[
{
cert_chain: tlsOptions.cert,
private_key: tlsOptions.key,
},
],
false
)
: grpc.ServerCredentials.createInsecure();

server.bindAsync(`0.0.0.0:${grpcPort}`, credentials, (error, port) => {
if (error) {
logger.fatal("Failed to start gRPC server", error as Error);
throw error;
}
);
logger.lifecycle("gRPC server listening", {
url: `0.0.0.0:${port}`,
});
});
}
Loading