From 7924794ae70b2d7c569bce56a0697528c4dd2e93 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 9 Jul 2026 02:13:05 -0600 Subject: [PATCH] perf: share db, docker and auth singletons across duplicated bundles The @dokploy/server modules get bundled multiple times per process (esbuild inline copy, compiled node_modules copy, and several Next.js chunks via transpilePackages), so every module-level side effect ran once per copy: up to 6 postgres pools, 6 dockerode clients and 6 better-auth instances in the server process, plus the repeated 'Using Docker socket' logs at boot. - db/index.ts: use the globalThis cache in production too (one pool per process) - constants/index.ts: cache the dockerode client on globalThis - lib/auth.ts: wrap betterAuth() in a factory and cache the instance - Dockerfile: exec node directly instead of leaving pnpm resident (~100MB RSS) --- Dockerfile | 3 +- packages/server/src/constants/index.ts | 12 +- packages/server/src/db/index.ts | 25 +- packages/server/src/lib/auth.ts | 748 +++++++++++++------------ 4 files changed, 400 insertions(+), 388 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4cab0e8f3..2d37692cc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -69,4 +69,5 @@ EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \ CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1 - CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"] +# Ejecutar node directamente: pnpm como wrapper queda residente (~100MB RSS) + CMD ["sh", "-c", "node -r dotenv/config dist/wait-for-postgres.mjs && node -r dotenv/config dist/migration.mjs && exec node -r dotenv/config dist/server.mjs"] diff --git a/packages/server/src/constants/index.ts b/packages/server/src/constants/index.ts index 706a0dbec..51ffeb8c4 100644 --- a/packages/server/src/constants/index.ts +++ b/packages/server/src/constants/index.ts @@ -81,7 +81,17 @@ const getDockerConfig = (): Docker => { return new Docker({ ...versionOption }); }; -export const docker = getDockerConfig(); +// Igual que db: el módulo se evalúa una vez por copia bundleada, el cache +// evita crear un cliente (y loguear la detección del socket) por cada una. +const globalForDocker = globalThis as unknown as { + docker?: Docker; +}; + +if (!globalForDocker.docker) { + globalForDocker.docker = getDockerConfig(); +} + +export const docker = globalForDocker.docker; export const paths = (isServer = false) => { const BASE_PATH = diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index 0dad31fd1..e648aa7f5 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -9,32 +9,19 @@ export * from "./schema"; type Database = PostgresJsDatabase; -/** - * Evita problemas de redeclaración global en monorepos. - * No usamos `declare global`. - */ +// Este módulo se evalúa varias veces por proceso (copias duplicadas por +// esbuild y los chunks de Next); el cache en globalThis garantiza un solo +// pool de conexiones por proceso. const globalForDb = globalThis as unknown as { db?: Database; }; -let dbConnection: Database; - -if (process.env.NODE_ENV === "production") { - // En producción no usamos global cache - dbConnection = drizzle(postgres(dbUrl), { +if (!globalForDb.db) { + globalForDb.db = drizzle(postgres(dbUrl), { schema, }); -} else { - // En desarrollo reutilizamos conexión para evitar múltiples conexiones - if (!globalForDb.db) { - globalForDb.db = drizzle(postgres(dbUrl), { - schema, - }); - } - - dbConnection = globalForDb.db; } -export const db: Database = dbConnection; +export const db: Database = globalForDb.db; export { dbUrl }; diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index 2e444009c..89f3f96cc 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -61,404 +61,418 @@ const resolveTrustedOrigins = async () => { } }; -const { handler, api } = betterAuth({ - database: drizzleAdapter(db, { - provider: "pg", - schema: schema, - }), - disabledPaths: [ - "/sso/register", - "/organization/create", - "/organization/update", - "/organization/delete", - ...(!IS_CLOUD ? ["/verify-email"] : []), - ], - secret: betterAuthSecret, - ...(!IS_CLOUD - ? { - advanced: { - useSecureCookies: false, - defaultCookieAttributes: { - sameSite: "lax", - secure: false, - httpOnly: true, - path: "/", - }, - }, - } - : {}), - - account: { - accountLinking: { - enabled: true, - async trustedProviders() { - const fromDb = await getTrustedProviders(); - return ["github", "google", ...fromDb]; - }, - allowDifferentEmails: true, - }, - }, - appName: "Dokploy", - socialProviders: { - github: { - clientId: process.env.GITHUB_CLIENT_ID as string, - clientSecret: process.env.GITHUB_CLIENT_SECRET as string, - }, - google: { - clientId: process.env.GOOGLE_CLIENT_ID as string, - clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, - }, - }, - logger: { - disabled: process.env.NODE_ENV === "production", - }, - trustedOrigins: resolveTrustedOrigins, - hooks: { - before: createAuthMiddleware(async (ctx) => { - ctx.context.trustedOrigins = [ - ...(ctx.context.baseURL ? [new URL(ctx.context.baseURL).origin] : []), - ...(await resolveTrustedOrigins()), - ].filter(Boolean); +const createBetterAuth = () => + betterAuth({ + database: drizzleAdapter(db, { + provider: "pg", + schema: schema, }), - }, - emailVerification: { - sendOnSignUp: true, - autoSignInAfterVerification: true, - sendOnSignIn: true, - sendVerificationEmail: async ({ user, url }) => { - if (IS_CLOUD) { - await sendVerificationEmail({ - userName: user.name || "User", + disabledPaths: [ + "/sso/register", + "/organization/create", + "/organization/update", + "/organization/delete", + ...(!IS_CLOUD ? ["/verify-email"] : []), + ], + secret: betterAuthSecret, + ...(!IS_CLOUD + ? { + advanced: { + useSecureCookies: false, + defaultCookieAttributes: { + sameSite: "lax", + secure: false, + httpOnly: true, + path: "/", + }, + }, + } + : {}), + + account: { + accountLinking: { + enabled: true, + async trustedProviders() { + const fromDb = await getTrustedProviders(); + return ["github", "google", ...fromDb]; + }, + allowDifferentEmails: true, + }, + }, + appName: "Dokploy", + socialProviders: { + github: { + clientId: process.env.GITHUB_CLIENT_ID as string, + clientSecret: process.env.GITHUB_CLIENT_SECRET as string, + }, + google: { + clientId: process.env.GOOGLE_CLIENT_ID as string, + clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, + }, + }, + logger: { + disabled: process.env.NODE_ENV === "production", + }, + trustedOrigins: resolveTrustedOrigins, + hooks: { + before: createAuthMiddleware(async (ctx) => { + ctx.context.trustedOrigins = [ + ...(ctx.context.baseURL ? [new URL(ctx.context.baseURL).origin] : []), + ...(await resolveTrustedOrigins()), + ].filter(Boolean); + }), + }, + emailVerification: { + sendOnSignUp: true, + autoSignInAfterVerification: true, + sendOnSignIn: true, + sendVerificationEmail: async ({ user, url }) => { + if (IS_CLOUD) { + await sendVerificationEmail({ + userName: user.name || "User", + email: user.email, + verificationUrl: url, + }); + } + }, + }, + emailAndPassword: { + enabled: true, + autoSignIn: !IS_CLOUD, + requireEmailVerification: + IS_CLOUD && process.env.NODE_ENV === "production", + password: { + async hash(password) { + return bcrypt.hashSync(password, 10); + }, + async verify({ hash, password }) { + return bcrypt.compareSync(password, hash); + }, + }, + sendResetPassword: async ({ user, url }) => { + await sendEmail({ email: user.email, - verificationUrl: url, - }); - } - }, - }, - emailAndPassword: { - enabled: true, - autoSignIn: !IS_CLOUD, - requireEmailVerification: IS_CLOUD && process.env.NODE_ENV === "production", - password: { - async hash(password) { - return bcrypt.hashSync(password, 10); - }, - async verify({ hash, password }) { - return bcrypt.compareSync(password, hash); - }, - }, - sendResetPassword: async ({ user, url }) => { - await sendEmail({ - email: user.email, - subject: "Reset your password", - text: ` + subject: "Reset your password", + text: `

Click the link to reset your password: Reset Password

`, - }); + }); + }, }, - }, - databaseHooks: { - user: { - create: { - before: async (_user, context) => { - if (!IS_CLOUD) { - const xDokployToken = - context?.request?.headers?.get("x-dokploy-token"); - if (xDokployToken) { - let invitation: Awaited>; + databaseHooks: { + user: { + create: { + before: async (_user, context) => { + if (!IS_CLOUD) { + const xDokployToken = + context?.request?.headers?.get("x-dokploy-token"); + if (xDokployToken) { + let invitation: Awaited>; + try { + invitation = await getUserByToken(xDokployToken); + } catch { + throw new APIError("BAD_REQUEST", { + message: "Invalid invitation token", + }); + } + if (invitation.isExpired) { + throw new APIError("BAD_REQUEST", { + message: "Invitation has expired", + }); + } + if (invitation.status !== "pending") { + throw new APIError("BAD_REQUEST", { + message: "Invitation has already been used", + }); + } + if ( + _user.email.toLowerCase().trim() !== + invitation.email.toLowerCase().trim() + ) { + throw new APIError("BAD_REQUEST", { + message: "Email does not match invitation", + }); + } + } else { + const isSSORequest = context?.path.includes("/sso"); + const isSCIMRequest = context?.path.includes("/scim"); + if (isSSORequest || isSCIMRequest) { + return; + } + const isAdminPresent = await db.query.member.findFirst({ + where: eq(schema.member.role, "owner"), + }); + if (isAdminPresent) { + throw new APIError("BAD_REQUEST", { + message: "Admin is already created", + }); + } + } + } + }, + after: async (user, context) => { + const isSSORequest = context?.path.includes("/sso"); + const isSCIMRequest = context?.path.includes("/scim"); + const isAdminPresent = await db.query.member.findFirst({ + where: eq(schema.member.role, "owner"), + }); + + if (!IS_CLOUD && !isAdminPresent) { + await updateWebServerSettings({ + serverIp: await getPublicIpWithFallback(), + }); + } + + if (IS_CLOUD) { try { - invitation = await getUserByToken(xDokployToken); - } catch { - throw new APIError("BAD_REQUEST", { - message: "Invalid invitation token", - }); - } - if (invitation.isExpired) { - throw new APIError("BAD_REQUEST", { - message: "Invitation has expired", - }); - } - if (invitation.status !== "pending") { - throw new APIError("BAD_REQUEST", { - message: "Invitation has already been used", - }); - } - if ( - _user.email.toLowerCase().trim() !== - invitation.email.toLowerCase().trim() - ) { - throw new APIError("BAD_REQUEST", { - message: "Email does not match invitation", - }); - } - } else { - const isSSORequest = context?.path.includes("/sso"); - const isSCIMRequest = context?.path.includes("/scim"); - if (isSSORequest || isSCIMRequest) { - return; - } - const isAdminPresent = await db.query.member.findFirst({ - where: eq(schema.member.role, "owner"), - }); - if (isAdminPresent) { - throw new APIError("BAD_REQUEST", { - message: "Admin is already created", - }); + const hutk = getHubSpotUTK( + context?.request?.headers?.get("cookie") || undefined, + ); + // Cast to include additional fields + const userWithFields = user as typeof user & { + lastName?: string; + }; + const hubspotSuccess = await submitToHubSpot( + { + email: user.email, + firstName: user.name || "", // name is mapped to firstName column + lastName: userWithFields.lastName || "", + }, + hutk, + ); + if (!hubspotSuccess) { + console.error("Failed to submit to HubSpot"); + } + } catch (error) { + console.error("Error submitting to HubSpot", error); } } - } - }, - after: async (user, context) => { - const isSSORequest = context?.path.includes("/sso"); - const isSCIMRequest = context?.path.includes("/scim"); - const isAdminPresent = await db.query.member.findFirst({ - where: eq(schema.member.role, "owner"), - }); - if (!IS_CLOUD && !isAdminPresent) { - await updateWebServerSettings({ - serverIp: await getPublicIpWithFallback(), - }); - } - - if (IS_CLOUD) { - try { - const hutk = getHubSpotUTK( - context?.request?.headers?.get("cookie") || undefined, - ); - // Cast to include additional fields - const userWithFields = user as typeof user & { - lastName?: string; - }; - const hubspotSuccess = await submitToHubSpot( - { - email: user.email, - firstName: user.name || "", // name is mapped to firstName column - lastName: userWithFields.lastName || "", - }, - hutk, - ); - if (!hubspotSuccess) { - console.error("Failed to submit to HubSpot"); - } - } catch (error) { - console.error("Error submitting to HubSpot", error); + if (isSCIMRequest) { + return; } - } - if (isSCIMRequest) { - return; - } + if (IS_CLOUD || !isAdminPresent) { + await db.transaction(async (tx) => { + const organization = await tx + .insert(schema.organization) + .values({ + name: "My Organization", + ownerId: user.id, + createdAt: new Date(), + }) + .returning() + .then((res) => res[0]); - if (IS_CLOUD || !isAdminPresent) { - await db.transaction(async (tx) => { - const organization = await tx - .insert(schema.organization) - .values({ - name: "My Organization", - ownerId: user.id, + await tx.insert(schema.member).values({ + userId: user.id, + organizationId: organization?.id || "", + role: "owner", createdAt: new Date(), - }) - .returning() - .then((res) => res[0]); + isDefault: true, // Mark first organization as default + }); + }); + } else if (isSSORequest) { + const providerId = context?.params?.providerId; + if (!providerId) { + throw new APIError("BAD_REQUEST", { + message: "Provider ID is required", + }); + } + const provider = await db.query.ssoProvider.findFirst({ + where: eq(schema.ssoProvider.providerId, providerId), + }); - await tx.insert(schema.member).values({ + if (!provider) { + throw new APIError("BAD_REQUEST", { + message: "Provider not found", + }); + } + await db.insert(schema.member).values({ userId: user.id, - organizationId: organization?.id || "", - role: "owner", + organizationId: provider?.organizationId || "", + role: "member", createdAt: new Date(), - isDefault: true, // Mark first organization as default - }); - }); - } else if (isSSORequest) { - const providerId = context?.params?.providerId; - if (!providerId) { - throw new APIError("BAD_REQUEST", { - message: "Provider ID is required", + isDefault: true, }); } - const provider = await db.query.ssoProvider.findFirst({ - where: eq(schema.ssoProvider.providerId, providerId), + }, + }, + }, + session: { + create: { + before: async (session) => { + // Find the default organization for this user + // Priority: 1) isDefault=true, 2) most recently created + const member = await db.query.member.findFirst({ + where: eq(schema.member.userId, session.userId), + orderBy: [ + desc(schema.member.isDefault), + desc(schema.member.createdAt), + ], + with: { + organization: true, + }, }); - if (!provider) { - throw new APIError("BAD_REQUEST", { - message: "Provider not found", - }); - } - await db.insert(schema.member).values({ - userId: user.id, - organizationId: provider?.organizationId || "", - role: "member", - createdAt: new Date(), - isDefault: true, + return { + data: { + ...session, + activeOrganizationId: member?.organization.id, + }, + }; + }, + after: async (session) => { + const orgId = ( + session as typeof session & { activeOrganizationId?: string } + ).activeOrganizationId; + if (!orgId) return; + const memberRecord = await db.query.member.findFirst({ + where: and( + eq(schema.member.userId, session.userId), + eq(schema.member.organizationId, orgId), + ), + with: { user: true }, }); - } + if (!memberRecord) return; + await createAuditLog({ + organizationId: orgId, + userId: session.userId, + userEmail: memberRecord.user.email, + userRole: memberRecord.role, + action: "login", + resourceType: "session", + }); + }, + }, + delete: { + after: async (session) => { + const orgId = ( + session as typeof session & { activeOrganizationId?: string } + ).activeOrganizationId; + if (!orgId) return; + const memberRecord = await db.query.member.findFirst({ + where: and( + eq(schema.member.userId, session.userId), + eq(schema.member.organizationId, orgId), + ), + with: { user: true }, + }); + if (!memberRecord) return; + await createAuditLog({ + organizationId: orgId, + userId: session.userId, + userEmail: memberRecord.user.email, + userRole: memberRecord.role, + action: "logout", + resourceType: "session", + }); + }, }, }, }, session: { - create: { - before: async (session) => { - // Find the default organization for this user - // Priority: 1) isDefault=true, 2) most recently created - const member = await db.query.member.findFirst({ - where: eq(schema.member.userId, session.userId), - orderBy: [ - desc(schema.member.isDefault), - desc(schema.member.createdAt), - ], - with: { - organization: true, - }, + expiresIn: 60 * 60 * 24 * 3, + updateAge: 60 * 60 * 24, + }, + user: { + modelName: "user", + fields: { + name: "firstName", // Map better-auth's default 'name' field to 'firstName' column + }, + additionalFields: { + role: { + type: "string", + // required: true, + input: false, + }, + ownerId: { + type: "string", + // required: true, + input: false, + }, + allowImpersonation: { + fieldName: "allowImpersonation", + type: "boolean", + defaultValue: false, + }, + lastName: { + type: "string", + required: false, + input: true, + defaultValue: "", + }, + enableEnterpriseFeatures: { + type: "boolean", + required: false, + input: false, + }, + isValidEnterpriseLicense: { + type: "boolean", + required: false, + input: false, + }, + }, + }, + plugins: [ + apiKey({ + enableMetadata: true, + references: "user", + }), + sso({ + saml: { + enableInResponseToValidation: false, + }, + }), + scim({ + beforeSCIMTokenGenerated: async ({ user }) => { + const dbUser = await db.query.user.findFirst({ + where: eq(schema.user.id, user.id), + columns: { enableEnterpriseFeatures: true }, }); - return { - data: { - ...session, - activeOrganizationId: member?.organization.id, - }, - }; + if (!dbUser?.enableEnterpriseFeatures) { + throw new APIError("FORBIDDEN", { + message: "SCIM provisioning requires an enterprise license", + }); + } }, - after: async (session) => { - const orgId = ( - session as typeof session & { activeOrganizationId?: string } - ).activeOrganizationId; - if (!orgId) return; - const memberRecord = await db.query.member.findFirst({ - where: and( - eq(schema.member.userId, session.userId), - eq(schema.member.organizationId, orgId), - ), - with: { user: true }, - }); - if (!memberRecord) return; - await createAuditLog({ - organizationId: orgId, - userId: session.userId, - userEmail: memberRecord.user.email, - userRole: memberRecord.role, - action: "login", - resourceType: "session", - }); + }), + twoFactor(), + organization({ + ac, + roles: { + owner: ownerRole, + admin: adminRole, + member: memberRole, }, - }, - delete: { - after: async (session) => { - const orgId = ( - session as typeof session & { activeOrganizationId?: string } - ).activeOrganizationId; - if (!orgId) return; - const memberRecord = await db.query.member.findFirst({ - where: and( - eq(schema.member.userId, session.userId), - eq(schema.member.organizationId, orgId), - ), - with: { user: true }, - }); - if (!memberRecord) return; - await createAuditLog({ - organizationId: orgId, - userId: session.userId, - userEmail: memberRecord.user.email, - userRole: memberRecord.role, - action: "logout", - resourceType: "session", - }); + dynamicAccessControl: { + enabled: true, + maximumRolesPerOrganization: 10, }, - }, - }, - }, - session: { - expiresIn: 60 * 60 * 24 * 3, - updateAge: 60 * 60 * 24, - }, - user: { - modelName: "user", - fields: { - name: "firstName", // Map better-auth's default 'name' field to 'firstName' column - }, - additionalFields: { - role: { - type: "string", - // required: true, - input: false, - }, - ownerId: { - type: "string", - // required: true, - input: false, - }, - allowImpersonation: { - fieldName: "allowImpersonation", - type: "boolean", - defaultValue: false, - }, - lastName: { - type: "string", - required: false, - input: true, - defaultValue: "", - }, - enableEnterpriseFeatures: { - type: "boolean", - required: false, - input: false, - }, - isValidEnterpriseLicense: { - type: "boolean", - required: false, - input: false, - }, - }, - }, - plugins: [ - apiKey({ - enableMetadata: true, - references: "user", - }), - sso({ - saml: { - enableInResponseToValidation: false, - }, - }), - scim({ - beforeSCIMTokenGenerated: async ({ user }) => { - const dbUser = await db.query.user.findFirst({ - where: eq(schema.user.id, user.id), - columns: { enableEnterpriseFeatures: true }, - }); + }), + // Self-hosted needs the admin plugin too: SCIM deactivation (active: false) + // maps to the admin plugin's `banned` field and is rejected without it. + // adminRoles: [] keeps every /admin/* endpoint locked on self-hosted. + admin( + IS_CLOUD + ? { adminUserIds: [process.env.USER_ADMIN_ID as string] } + : { adminRoles: [] }, + ), + ], + }); - if (!dbUser?.enableEnterpriseFeatures) { - throw new APIError("FORBIDDEN", { - message: "SCIM provisioning requires an enterprise license", - }); - } - }, - }), - twoFactor(), - organization({ - ac, - roles: { - owner: ownerRole, - admin: adminRole, - member: memberRole, - }, - dynamicAccessControl: { - enabled: true, - maximumRolesPerOrganization: 10, - }, - }), - // Self-hosted needs the admin plugin too: SCIM deactivation (active: false) - // maps to the admin plugin's `banned` field and is rejected without it. - // adminRoles: [] keeps every /admin/* endpoint locked on self-hosted. - admin( - IS_CLOUD - ? { adminUserIds: [process.env.USER_ADMIN_ID as string] } - : { adminRoles: [] }, - ), - ], -}); +// Una sola instancia de better-auth por proceso aunque el módulo esté +// duplicado en varios bundles. +const globalForAuth = globalThis as unknown as { + betterAuthInstance?: ReturnType; +}; + +if (!globalForAuth.betterAuthInstance) { + globalForAuth.betterAuthInstance = createBetterAuth(); +} + +const { handler, api } = globalForAuth.betterAuthInstance; const _auth = { handler,