Skip to content
Open
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
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: CI

on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]

jobs:
build-and-test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10.20.0

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'

- name: Install dependencies
run: pnpm install

- name: Generate Prisma Client
run: cd apps/backend && pnpm prisma generate

- name: Run Backend Tests
run: cd apps/backend && pnpm test

- name: Lint
run: pnpm lint
8 changes: 8 additions & 0 deletions apps/backend/jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
};
6 changes: 5 additions & 1 deletion apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"p:f": "prisma format --schema=prisma/schema.prisma",
"p:g": "prisma generate --schema=prisma/schema.prisma",
"p:m": "prisma migrate dev --schema=prisma/schema.prisma",
"postinstall": "prisma generate"
"postinstall": "prisma generate",
"test": "NODE_OPTIONS=--experimental-vm-modules jest"
},
"keywords": [],
"author": "",
Expand All @@ -40,10 +41,13 @@
"@prisma/client": "^6.19.0",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.5",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.10.1",
"@types/socket.io": "^3.0.2",
"jest": "^29.7.0",
"prisma": "^6.19.0",
"ts-jest": "^29.4.9",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.9.3"
Expand Down
11 changes: 11 additions & 0 deletions apps/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import chatRoutes from "./routes/chat.routes.js";
import userRoutes from "./routes/user.routes.js";
import { isAllowedOrigin } from "./config/origin.js";
import webrtcRoutes from "./routes/webrtc.routes.js"
import { z } from "zod";

export const app: Express = express();

Expand Down Expand Up @@ -64,6 +65,16 @@ app.use("/webrtc", webrtcRoutes)

// Error handler to catch all unhandled errors
app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => {
if (err instanceof z.ZodError) {
return res.status(400).json({
message: "Validation Error",
errors: err.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})),
})
}

console.error('[ERROR] Unhandled error:', err)
console.error('[ERROR] Stack:', err?.stack)
res.status(500).json({
Expand Down
33 changes: 33 additions & 0 deletions apps/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,39 @@ io.on('connection', async (socket) => {

server.listen(port, () => { console.log(`Server running on port ${port}`) })

const gracefulShutdown = async (signal: string) => {
console.log(`\n${signal} received. Shutting down gracefully...`)

clearInterval(presenceCleanup)

server.close(async (err) => {
if (err) {
console.error('Error closing server:', err)
process.exit(1)
}

console.log('HTTP server closed.')

try {
await prisma.$disconnect()
console.log('Database connection closed.')
process.exit(0)
} catch (dbErr) {
console.error('Error during database disconnection:', dbErr)
process.exit(1)
}
})

// Force close after 10 seconds if graceful shutdown fails
setTimeout(() => {
console.error('Could not close connections in time, forcefully shutting down')
process.exit(1)
}, 10000)
Comment on lines +114 to +141
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Shutdown skips socket.io close 🐞 Bug ☼ Reliability

In apps/backend/src/index.ts, gracefulShutdown() calls server.close() but never closes the Socket.IO
server, so long-lived websocket connections can prevent the close callback from firing and the code
will hit the 10s forced process.exit(1). This defeats the “graceful” shutdown and can terminate
clients/cleanup abruptly even when the only remaining work is open sockets.
Agent Prompt
### Issue description
`gracefulShutdown()` closes the HTTP server and Prisma but leaves Socket.IO running. With active websocket connections, `server.close()` may never invoke its callback, causing the 10s timeout to force `process.exit(1)`.

### Issue Context
The backend uses Socket.IO for real-time features; sockets are long-lived and need an explicit shutdown to let the HTTP server fully close.

### Fix Focus Areas
- apps/backend/src/index.ts[114-141]

### Suggested fix
- Add an idempotency guard (e.g., `let isShuttingDown = false`) to avoid double shutdown on repeated signals.
- Explicitly stop Socket.IO before/alongside `server.close()`, e.g.:
  - `io.disconnectSockets(true)` (optional, if you want to actively drop clients)
  - `io.close()` to close the engine and release handles
- Store the force-timeout handle and `clearTimeout()` it when shutdown completes.
- Consider calling `await prisma.$disconnect()` in the force-timeout path (best-effort) before exiting.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}

process.on('SIGINT', () => gracefulShutdown('SIGINT'))
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))

process.on("exit", () => {
clearInterval(presenceCleanup)
})
39 changes: 39 additions & 0 deletions apps/backend/src/utils/resolveAssetUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals'
import { resolveAssetUrl } from './resolveAssetUrl.js'

describe('resolveAssetUrl', () => {
const originalEnv = process.env

beforeEach(() => {
jest.resetModules()
process.env = { ...originalEnv }
})

it('should return null if path is null or undefined', () => {
expect(resolveAssetUrl(null)).toBe(null)
expect(resolveAssetUrl(undefined)).toBe(null)
})

it('should return the path as is if it is an absolute URL', () => {
const absoluteUrl = 'https://example.com/image.png'
expect(resolveAssetUrl(absoluteUrl)).toBe(absoluteUrl)
})

it('should resolve local path with BASE_URL', () => {
process.env.BASE_URL = 'http://localhost:4000'
const localPath = 'avatar.png'
expect(resolveAssetUrl(localPath)).toBe('http://localhost:4000/uploads/avatar.png')
})

it('should handle leading slash in path', () => {
process.env.BASE_URL = 'http://localhost:4000'
const localPath = '/custom-path.png'
expect(resolveAssetUrl(localPath)).toBe('http://localhost:4000/custom-path.png')
})

it('should return path as is if BASE_URL is not set', () => {
delete process.env.BASE_URL
const localPath = 'avatar.png'
expect(resolveAssetUrl(localPath)).toBe('avatar.png')
})
})
Loading
Loading