fix(backend): IMAP ECONNRESET crash-loop + dm-push ESM interop
- mail/scan{,-internal}.post.ts + imap-idle: attach imap.on('error')
+ targeted uncaughtException/unhandledRejection guards so a
connection-level IMAP error (ECONNRESET / TLS disconnect) can no
longer propagate to a process-level uncaughtException and kill the
Nitro API (root cause of the staging 502 crash-loop)
- services/push.ts: lazy dynamic-import expo-server-sdk (singleton,
like voip-push.ts) to fix "Class extends value [object Module]"
(ESM/CJS undici interop) that broke DM push notifications;
+ nitro.config externals safety net
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
1f20056ef3
commit
1493752634
@ -601,6 +601,19 @@ async function runSession(conn) {
|
|||||||
disableCompression: conn.imapHost.includes("office365"),
|
disableCompression: conn.imapHost.includes("office365"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ImapFlow kann socket-level Errors (ECONNRESET, TLS-disconnect, ETIMEDOUT)
|
||||||
|
// als EventEmitter-'error'-Event feuern — ZUSÄTZLICH zu oder STATT eines
|
||||||
|
// rejected Promise. Ohne diesen Handler eskaliert Node zu uncaughtException
|
||||||
|
// → Prozess-Exit → alle 44+ Sessions fallen gleichzeitig aus.
|
||||||
|
// Dieser Handler absorbiert das Event account-lokal; der bestehende catch-Block
|
||||||
|
// weiter unten greift für den Promise-Rejection-Pfad.
|
||||||
|
imap.on("error", (err) => {
|
||||||
|
logError(conn.email, "imap socket error (absorbed, reconnect-loop handles it)", err);
|
||||||
|
// imap.close() hier ist safe: idempotent, löst idlePromise-rejection aus
|
||||||
|
// → IDLE-Loop verlässt await idlePromise → runSession-catch greift.
|
||||||
|
try { imap.close(); } catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
|
||||||
// Referenz ablegen damit shutdown() darauf zugreifen kann
|
// Referenz ablegen damit shutdown() darauf zugreifen kann
|
||||||
const handle = sessions.get(conn.id);
|
const handle = sessions.get(conn.id);
|
||||||
if (handle) handle.imap = imap;
|
if (handle) handle.imap = imap;
|
||||||
@ -876,6 +889,63 @@ async function shutdown(signal) {
|
|||||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||||
|
|
||||||
|
// ─── Letztes Netz: bekannte IMAP-Socket-Errors als uncaughtException ──────────
|
||||||
|
// Obwohl per-Account imap.on('error') und try/catch die primären Abfanggräben
|
||||||
|
// sind, kann ein Timing-Fenster (z.B. Error-Event feuert zwischen close() und
|
||||||
|
// neuem imap-Objekt) doch auf Prozessebene ankommen.
|
||||||
|
// Dieser Guard schluckt NUR bekannte IMAP/Socket-Fehler (ECONNRESET, ETIMEDOUT,
|
||||||
|
// ECONNREFUSED, EPIPE, EHOSTUNREACH, TLS-Fehler). Alles andere (Bug im Daemon-
|
||||||
|
// Code selbst) wird NICHT geschluckt — normaler uncaughtException-Exit bleibt.
|
||||||
|
const IMAP_SOCKET_ERROR_CODES = new Set([
|
||||||
|
"ECONNRESET", "ETIMEDOUT", "ECONNREFUSED", "EPIPE",
|
||||||
|
"EHOSTUNREACH", "ENOTFOUND", "ENETUNREACH",
|
||||||
|
]);
|
||||||
|
|
||||||
|
process.on("uncaughtException", (err, origin) => {
|
||||||
|
const code = err?.code;
|
||||||
|
const msg = err?.message ?? "";
|
||||||
|
|
||||||
|
const isKnownImapError =
|
||||||
|
(code && IMAP_SOCKET_ERROR_CODES.has(code)) ||
|
||||||
|
msg.includes("Client network socket disconnected") ||
|
||||||
|
msg.includes("before secure TLS connection") ||
|
||||||
|
msg.includes("ECONNRESET") ||
|
||||||
|
msg.includes("socket hang up");
|
||||||
|
|
||||||
|
if (isKnownImapError) {
|
||||||
|
console.error(
|
||||||
|
`[idle] uncaughtException absorbed (known IMAP/socket error) — origin=${origin} code=${code ?? "?"}: ${msg}`,
|
||||||
|
);
|
||||||
|
// Daemon läuft weiter — Reconnect-Loop der betroffenen Session übernimmt
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unbekannter Fehler: normal crashen (kein stummes Schlucken von Bugs)
|
||||||
|
console.error(`[idle] uncaughtException (non-IMAP, crashing) — origin=${origin}:`, err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("unhandledRejection", (reason, promise) => {
|
||||||
|
const msg = (reason instanceof Error ? reason.message : String(reason)) ?? "";
|
||||||
|
const code = (reason instanceof Error ? /** @type {any} */ (reason).code : undefined);
|
||||||
|
|
||||||
|
const isKnownImapError =
|
||||||
|
(code && IMAP_SOCKET_ERROR_CODES.has(code)) ||
|
||||||
|
msg.includes("Client network socket disconnected") ||
|
||||||
|
msg.includes("before secure TLS connection") ||
|
||||||
|
msg.includes("ECONNRESET") ||
|
||||||
|
msg.includes("socket hang up");
|
||||||
|
|
||||||
|
if (isKnownImapError) {
|
||||||
|
console.error(
|
||||||
|
`[idle] unhandledRejection absorbed (known IMAP/socket error): ${msg}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("[idle] unhandledRejection (non-IMAP):", reason);
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Startup ──────────────────────────────────────────────────────────────────
|
// ─── Startup ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
|
|||||||
@ -10,11 +10,15 @@ export default defineNitroConfig({
|
|||||||
// Default-publicAssets greift nicht zuverlässig wenn srcDir auf "server" zeigt.
|
// Default-publicAssets greift nicht zuverlässig wenn srcDir auf "server" zeigt.
|
||||||
publicAssets: [{ baseURL: "/", dir: "../public", maxAge: 60 * 60 }],
|
publicAssets: [{ baseURL: "/", dir: "../public", maxAge: 60 * 60 }],
|
||||||
|
|
||||||
// Supabase + imapflow als external deps — nicht bundlen.
|
// Supabase + imapflow + expo-server-sdk als external deps — nicht bundlen.
|
||||||
// imapflow nutzt CJS-inherits-Pattern, bricht beim Bundlen zu ESM
|
// imapflow nutzt CJS-inherits-Pattern, bricht beim Bundlen zu ESM
|
||||||
// ("superCtor.prototype must be of type object"). @parse/node-apn wird
|
// ("superCtor.prototype must be of type object"). @parse/node-apn wird
|
||||||
// in services/voip-push.ts via dynamic import geladen (vermeidet das gleiche
|
// in services/voip-push.ts via dynamic import geladen (vermeidet das gleiche
|
||||||
// Problem ohne Externalize-Eintrag).
|
// Problem ohne Externalize-Eintrag).
|
||||||
|
// expo-server-sdk v6+ ist type:"module" und importiert undici (CJS) intern via
|
||||||
|
// ESM-Syntax. Nitro/Rollup kann diesen ESM→CJS-Interop nicht korrekt bundlen:
|
||||||
|
// → `Class extends value [object Module] is not a constructor` in push.mjs.
|
||||||
|
// Fix: external halten, läuft zur Laufzeit aus node_modules korrekt als ESM.
|
||||||
externals: {
|
externals: {
|
||||||
// imapflow MUSS external bleiben — nutzt CJS-inherits-Pattern, bricht beim
|
// imapflow MUSS external bleiben — nutzt CJS-inherits-Pattern, bricht beim
|
||||||
// Bundlen zu ESM ("superCtor.prototype must be of type object", util.inherits).
|
// Bundlen zu ESM ("superCtor.prototype must be of type object", util.inherits).
|
||||||
@ -22,8 +26,14 @@ export default defineNitroConfig({
|
|||||||
// Specifier "imapflow", nicht auf aufgelöste node_modules-Pfade. Bei Module-Graph-
|
// Specifier "imapflow", nicht auf aufgelöste node_modules-Pfade. Bei Module-Graph-
|
||||||
// Shifts (z.B. neue Prisma-Felder) wurde imapflow doch inlined → scan-internal 500
|
// Shifts (z.B. neue Prisma-Felder) wurde imapflow doch inlined → scan-internal 500
|
||||||
// (Incident 2026-06-05). Expliziter external-Eintrag mit Pfad-Regex erzwingt es robust.
|
// (Incident 2026-06-05). Expliziter external-Eintrag mit Pfad-Regex erzwingt es robust.
|
||||||
external: [/(^|[\\/]node_modules[\\/])imapflow([\\/]|$)/],
|
//
|
||||||
inline: [/^(?!@supabase\/supabase-js)(?!imapflow)/],
|
// expo-server-sdk: dasselbe Robustness-Prinzip — Pfad-Regex statt reiner Lookahead,
|
||||||
|
// weil Rollup bei transitivem Import-Graph den nackten Specifier nicht immer sieht.
|
||||||
|
external: [
|
||||||
|
/(^|[\\/]node_modules[\\/])imapflow([\\/]|$)/,
|
||||||
|
/(^|[\\/]node_modules[\\/])expo-server-sdk([\\/]|$)/,
|
||||||
|
],
|
||||||
|
inline: [/^(?!@supabase\/supabase-js)(?!imapflow)(?!expo-server-sdk)/],
|
||||||
},
|
},
|
||||||
|
|
||||||
imports: {
|
imports: {
|
||||||
|
|||||||
@ -94,6 +94,17 @@ export default defineEventHandler(async (event) => {
|
|||||||
tls: { rejectUnauthorized: connection.rejectUnauthorized ?? true },
|
tls: { rejectUnauthorized: connection.rejectUnauthorized ?? true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ImapFlow kann socket-level Errors (ECONNRESET, TLS-disconnect) als EventEmitter-
|
||||||
|
// 'error'-Event feuern — ZUSÄTZLICH zu oder STATT eines rejected Promise.
|
||||||
|
// Ohne diesen Handler eskaliert Node das zu einem uncaughtException, das den
|
||||||
|
// gesamten Prozess killt. Dieser Handler absorbiert es account-lokal.
|
||||||
|
imap.on("error", (err: Error) => {
|
||||||
|
console.error(
|
||||||
|
`[scan-internal] imap socket error for ${connection.email} (absorbed, non-fatal):`,
|
||||||
|
err?.message ?? err,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
let scanned = 0;
|
let scanned = 0;
|
||||||
let newlyBlocked = 0;
|
let newlyBlocked = 0;
|
||||||
|
|
||||||
|
|||||||
@ -82,6 +82,16 @@ export default defineEventHandler(async (event) => {
|
|||||||
tls: { rejectUnauthorized: connection.rejectUnauthorized ?? true },
|
tls: { rejectUnauthorized: connection.rejectUnauthorized ?? true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Socket-level Errors (ECONNRESET, TLS-disconnect) können als EventEmitter-
|
||||||
|
// 'error'-Event kommen statt als rejected Promise → uncaughtException ohne
|
||||||
|
// diesen Handler.
|
||||||
|
imap.on("error", (err: Error) => {
|
||||||
|
console.error(
|
||||||
|
`[scan] imap socket error for ${connection.email} (absorbed, non-fatal):`,
|
||||||
|
err?.message ?? err,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
let scanned = 0;
|
let scanned = 0;
|
||||||
let newlyBlocked = 0;
|
let newlyBlocked = 0;
|
||||||
|
|
||||||
|
|||||||
@ -11,12 +11,33 @@
|
|||||||
* Token-Cleanup: Bei DeviceNotRegistered Receipts werden Tokens automatisch
|
* Token-Cleanup: Bei DeviceNotRegistered Receipts werden Tokens automatisch
|
||||||
* disabled (PushToken.enabled = false) — Re-Enable nur durch Re-Registrierung
|
* disabled (PushToken.enabled = false) — Re-Enable nur durch Re-Registrierung
|
||||||
* vom Client.
|
* vom Client.
|
||||||
|
*
|
||||||
|
* IMPORT-HINWEIS: expo-server-sdk v6+ ist type:"module" und importiert undici (CJS)
|
||||||
|
* intern via ESM-Syntax. Nitro/Rollup kann diesen ESM→CJS-Interop beim statischen
|
||||||
|
* Bundlen nicht korrekt auflösen → `Class extends value [object Module] is not a
|
||||||
|
* constructor` zur Laufzeit. Workaround: dynamic import (analog @parse/node-apn in
|
||||||
|
* voip-push.ts), sodass das Modul erst zur Laufzeit aus node_modules geladen wird.
|
||||||
*/
|
*/
|
||||||
import { Expo, type ExpoPushMessage } from "expo-server-sdk";
|
|
||||||
import { usePrisma } from "../utils/prisma";
|
import { usePrisma } from "../utils/prisma";
|
||||||
import { sendVoIPPush } from "./voip-push";
|
import { sendVoIPPush } from "./voip-push";
|
||||||
|
|
||||||
const expo = new Expo();
|
// expo-server-sdk lazy-geladen via dynamic import — verhindert Rollup-ESM/CJS-Interop-Bug.
|
||||||
|
type ExpoModule = typeof import("expo-server-sdk");
|
||||||
|
type ExpoInstance = InstanceType<ExpoModule["Expo"]>;
|
||||||
|
type ExpoPushMessage = ExpoModule["ExpoPushMessage"];
|
||||||
|
|
||||||
|
let _expoMod: ExpoModule | null = null;
|
||||||
|
let _expo: ExpoInstance | null = null;
|
||||||
|
|
||||||
|
async function ensureExpo(): Promise<{ expo: ExpoInstance; Expo: ExpoModule["Expo"] }> {
|
||||||
|
if (_expo && _expoMod) return { expo: _expo, Expo: _expoMod.Expo };
|
||||||
|
const mod = (await import("expo-server-sdk")) as unknown as ExpoModule & {
|
||||||
|
default?: ExpoModule;
|
||||||
|
};
|
||||||
|
_expoMod = mod.default ?? mod;
|
||||||
|
_expo = new _expoMod.Expo();
|
||||||
|
return { expo: _expo, Expo: _expoMod.Expo };
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatPushPayload {
|
export interface ChatPushPayload {
|
||||||
/** Empfänger-User-ID (kann mehrere Tokens haben) */
|
/** Empfänger-User-ID (kann mehrere Tokens haben) */
|
||||||
@ -37,6 +58,7 @@ export interface ChatPushPayload {
|
|||||||
export async function sendChatPush(payload: ChatPushPayload): Promise<void> {
|
export async function sendChatPush(payload: ChatPushPayload): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
|
const { expo, Expo } = await ensureExpo();
|
||||||
|
|
||||||
// 1) Profile-Opt-out prüfen
|
// 1) Profile-Opt-out prüfen
|
||||||
const profile = await db.profile.findUnique({
|
const profile = await db.profile.findUnique({
|
||||||
@ -161,6 +183,7 @@ export async function sendDeviceAddedPush(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
|
const { expo, Expo } = await ensureExpo();
|
||||||
|
|
||||||
const profile = await db.profile.findUnique({
|
const profile = await db.profile.findUnique({
|
||||||
where: { id: payload.userId },
|
where: { id: payload.userId },
|
||||||
@ -250,6 +273,7 @@ export interface CallRingPushPayload {
|
|||||||
export async function sendCallRingPush(payload: CallRingPushPayload): Promise<void> {
|
export async function sendCallRingPush(payload: CallRingPushPayload): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
|
const { expo, Expo } = await ensureExpo();
|
||||||
|
|
||||||
const profile = await db.profile.findUnique({
|
const profile = await db.profile.findUnique({
|
||||||
where: { id: payload.receiverId },
|
where: { id: payload.receiverId },
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user