diff --git a/Dockerfile b/Dockerfile index e4fe7ae69c..417de10833 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,11 @@ ARG APP_PATH WORKDIR $APP_PATH ENV NODE_ENV=production +# Limit glibc malloc arenas, which default to 8 per CPU. Each arena can hold +# onto 64MB of virtual memory and freed allocations, which inflates resident +# memory in multi-threaded Node.js processes for no performance benefit here. +ENV MALLOC_ARENA_MAX=2 + # Create a non-root user compatible with Debian and BusyBox based images RUN addgroup --gid 1001 nodejs && \ adduser --uid 1001 --ingroup nodejs nodejs && \ diff --git a/server/index.ts b/server/index.ts index 62a90be278..1e01480949 100644 --- a/server/index.ts +++ b/server/index.ts @@ -7,6 +7,7 @@ import "./logging/tracer"; // must come before importing any instrumented module import http from "node:http"; import https from "node:https"; +import os from "node:os"; import type { Context } from "koa"; import Koa from "koa"; import helmet from "koa-helmet"; @@ -21,7 +22,11 @@ import services from "./services"; import { getArg } from "./utils/args"; import { getSSLOptions } from "./utils/ssl"; import { defaultRateLimiter } from "@server/middlewares/rateLimiter"; -import { printEnv, checkPendingMigrations } from "./utils/startup"; +import { + printEnv, + checkPendingMigrations, + configureChildHeapLimit, +} from "./utils/startup"; import { checkUpdates } from "./utils/updates"; import onerror from "./onerror"; import ShutdownHelper, { ShutdownOrder } from "./utils/ShutdownHelper"; @@ -262,6 +267,16 @@ const isWebProcess = const isWorkerProcess = env.SERVICES.length === 1 && env.SERVICES.includes("worker"); +// Mirrors the `count` passed to throng below, where undefined falls back to +// throng's default of one process per CPU. +const processCount = isWorkerProcess + ? 1 + : isWebProcess + ? (webProcessCount ?? os.cpus().length) + : os.cpus().length; + +configureChildHeapLimit(processCount); + void throng({ master, worker: start, diff --git a/server/storage/files/S3Storage.test.ts b/server/storage/files/S3Storage.test.ts index 0eb5dc9add..f024a59645 100644 --- a/server/storage/files/S3Storage.test.ts +++ b/server/storage/files/S3Storage.test.ts @@ -23,7 +23,8 @@ describe("S3Storage", () => { } ); const storage = new S3Storage(); - vi.spyOn(Reflect.get(storage, "client"), "send").mockRejectedValue(error); + const { client } = await Reflect.get(storage, "getS3").call(storage); + vi.spyOn(client, "send").mockRejectedValue(error); const errorSpy = vi.spyOn(Logger, "error"); const infoSpy = vi.spyOn(Logger, "info"); @@ -40,7 +41,8 @@ describe("S3Storage", () => { $metadata: { httpStatusCode: 500 }, }); const storage = new S3Storage(); - vi.spyOn(Reflect.get(storage, "client"), "send").mockRejectedValue(error); + const { client } = await Reflect.get(storage, "getS3").call(storage); + vi.spyOn(client, "send").mockRejectedValue(error); const errorSpy = vi.spyOn(Logger, "error"); const stream = await storage.getFileStream("some/key"); diff --git a/server/storage/files/S3Storage.ts b/server/storage/files/S3Storage.ts index 31bb43cca4..84da56d176 100644 --- a/server/storage/files/S3Storage.ts +++ b/server/storage/files/S3Storage.ts @@ -1,19 +1,8 @@ import path from "node:path"; import type { Readable } from "node:stream"; -import type { ObjectCannedACL } from "@aws-sdk/client-s3"; -import { - S3Client, - DeleteObjectCommand, - GetObjectCommand, - HeadObjectCommand, - CopyObjectCommand, - PutObjectCommand, -} from "@aws-sdk/client-s3"; -import { Upload } from "@aws-sdk/lib-storage"; +import type * as AwsS3 from "@aws-sdk/client-s3"; +import type { ObjectCannedACL, S3Client } from "@aws-sdk/client-s3"; import type { PresignedPostOptions } from "@aws-sdk/s3-presigned-post"; -import { createPresignedPost } from "@aws-sdk/s3-presigned-post"; -import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; -import { getSignedUrl as getCloudFrontSignedUrl } from "@aws-sdk/cloudfront-signer"; import fs from "fs-extra"; import invariant from "invariant"; import { compact } from "es-toolkit/compat"; @@ -24,23 +13,11 @@ import Logger from "@server/logging/Logger"; import BaseStorage from "./BaseStorage"; import type { AppContext } from "@server/types"; +// The AWS SDK packages are imported dynamically inside methods rather than at +// module top-level so they are only loaded into memory once S3 storage is +// used — this module itself is imported regardless of the configured storage +// backend. Only the packages' type imports are free of runtime cost. export default class S3Storage extends BaseStorage { - constructor() { - super(); - - // Loaded here rather than at module top-level so the native CRT binding - // only loads when S3 storage is actually used, keeping it off startup. - // https://github.com/aws/aws-sdk-js-v3#functionality-requiring-aws-common-runtime-crt - require("@aws-sdk/signature-v4-crt"); - - this.client = new S3Client({ - bucketEndpoint: env.AWS_S3_ACCELERATE_URL ? true : false, - forcePathStyle: env.AWS_S3_FORCE_PATH_STYLE, - region: env.AWS_REGION, - endpoint: this.getEndpoint(), - }); - } - public async getPresignedPost( _ctx: AppContext, key: string, @@ -66,7 +43,9 @@ export default class S3Storage extends BaseStorage { Expires: 3600, }; - return createPresignedPost(this.client, params); + const { createPresignedPost } = await import("@aws-sdk/s3-presigned-post"); + const { client } = await this.getS3(); + return createPresignedPost(client, params); } /** @@ -88,7 +67,8 @@ export default class S3Storage extends BaseStorage { const contentDisposition = this.getContentDisposition(contentType); const cacheControl = "max-age=31557600"; - const command = new PutObjectCommand({ + const { sdk, client } = await this.getS3(); + const command = new sdk.PutObjectCommand({ Bucket: this.getBucket(), Key: key, ContentType: contentType, @@ -98,7 +78,8 @@ export default class S3Storage extends BaseStorage { ...(env.AWS_S3_ACL && { ACL: env.AWS_S3_ACL as ObjectCannedACL }), }); - let url = await getSignedUrl(this.client, command, { + const { getSignedUrl } = await import("@aws-sdk/s3-request-presigner"); + let url = await getSignedUrl(client, command, { expiresIn: 3600, }); @@ -174,8 +155,10 @@ export default class S3Storage extends BaseStorage { key: string; acl?: string; }) => { + const { Upload } = await import("@aws-sdk/lib-storage"); + const { client } = await this.getS3(); const upload = new Upload({ - client: this.client, + client, params: { ...(env.AWS_S3_ACL && { ACL: env.AWS_S3_ACL as ObjectCannedACL }), Bucket: this.getBucket(), @@ -194,8 +177,9 @@ export default class S3Storage extends BaseStorage { }; public async deleteFile(key: string) { - await this.client.send( - new DeleteObjectCommand({ + const { sdk, client } = await this.getS3(); + await client.send( + new sdk.DeleteObjectCommand({ Bucket: this.getBucket(), Key: key, }) @@ -219,6 +203,9 @@ export default class S3Storage extends BaseStorage { const cfUrl = this.getCloudFrontUrlForKey(key); try { + const { getSignedUrl: getCloudFrontSignedUrl } = await import( + "@aws-sdk/cloudfront-signer" + ); return getCloudFrontSignedUrl({ url: cfUrl, keyPairId: env.AWS_CLOUDFRONT_KEY_PAIR_ID, @@ -273,10 +260,11 @@ export default class S3Storage extends BaseStorage { }); } - public getFileExists(key: string): Promise { - return this.client + public async getFileExists(key: string): Promise { + const { sdk, client } = await this.getS3(); + return client .send( - new HeadObjectCommand({ + new sdk.HeadObjectCommand({ Bucket: this.getBucket(), Key: key, }) @@ -286,28 +274,30 @@ export default class S3Storage extends BaseStorage { } public moveFile = async (fromKey: string, toKey: string) => { - await this.client.send( - new CopyObjectCommand({ + const { sdk, client } = await this.getS3(); + await client.send( + new sdk.CopyObjectCommand({ Bucket: this.getBucket(), CopySource: `${env.AWS_S3_UPLOAD_BUCKET_NAME}/${fromKey}`, Key: toKey, }) ); - await this.client.send( - new DeleteObjectCommand({ + await client.send( + new sdk.DeleteObjectCommand({ Bucket: this.getBucket(), Key: fromKey, }) ); }; - public getFileStream( + public async getFileStream( key: string, range?: { start: number; end: number } ): Promise { - return this.client + const { sdk, client } = await this.getS3(); + return client .send( - new GetObjectCommand({ + new sdk.GetObjectCommand({ Bucket: this.getBucket(), Key: key, Range: range ? `bytes=${range.start}-${range.end}` : undefined, @@ -364,7 +354,31 @@ export default class S3Storage extends BaseStorage { return undefined; } - private client: S3Client; + private s3Promise?: Promise<{ sdk: typeof AwsS3; client: S3Client }>; + + /** + * Returns the S3 SDK module and client, loading both on first use. Loading + * is deferred so the AWS SDK and its native CRT binding are not loaded at + * startup. + */ + private getS3(): Promise<{ sdk: typeof AwsS3; client: S3Client }> { + this.s3Promise ??= (async () => { + // Must be loaded before the client is constructed so SigV4a request + // signing is available. + // https://github.com/aws/aws-sdk-js-v3#functionality-requiring-aws-common-runtime-crt + await import("@aws-sdk/signature-v4-crt"); + + const sdk = await import("@aws-sdk/client-s3"); + const client = new sdk.S3Client({ + bucketEndpoint: env.AWS_S3_ACCELERATE_URL ? true : false, + forcePathStyle: env.AWS_S3_FORCE_PATH_STYLE, + region: env.AWS_REGION, + endpoint: this.getEndpoint(), + }); + return { sdk, client }; + })(); + return this.s3Promise; + } private getCloudFrontUrlForKey(key: string): string { if (!env.AWS_CLOUDFRONT_URL) { @@ -404,8 +418,10 @@ export default class S3Storage extends BaseStorage { // Ensure expiration does not exceed AWS S3 Signature V4 limit of 7 days const clampedExpiresIn = Math.min(expiresIn, S3Storage.maxSignedUrlExpires); - const command = new GetObjectCommand(params); - const url = await getSignedUrl(this.client, command, { + const { sdk, client } = await this.getS3(); + const { getSignedUrl } = await import("@aws-sdk/s3-request-presigner"); + const command = new sdk.GetObjectCommand(params); + const url = await getSignedUrl(client, command, { expiresIn: clampedExpiresIn, }); diff --git a/server/storage/files/index.ts b/server/storage/files/index.ts index 08e11a8ac2..b4420c351f 100644 --- a/server/storage/files/index.ts +++ b/server/storage/files/index.ts @@ -1,8 +1,12 @@ import env from "@server/env"; +import type BaseStorage from "./BaseStorage"; import LocalStorage from "./LocalStorage"; import S3Storage from "./S3Storage"; -const storage = +// Only the configured backend is instantiated. S3Storage requires the AWS SDK +// lazily, so the SDK and its native CRT binding are never loaded into memory +// when local file storage is in use. +const storage: BaseStorage = env.FILE_STORAGE === "local" ? new LocalStorage() : new S3Storage(); export default storage; diff --git a/server/test/setup.ts b/server/test/setup.ts index d42ecde496..4a9c04bb4c 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -13,13 +13,18 @@ afterAll(() => server.close()); // This needs to be done before any modules that use EventEmitter are loaded EventEmitter.defaultMaxListeners = 100; -// Mock AWS SDK S3 client and related commands +// Mock AWS SDK S3 client and related commands. The client must be a real +// class as it is instantiated with `new` via a dynamic import, which does not +// apply the spy-constructor interop that static imports receive. vi.mock("@aws-sdk/client-s3", () => ({ - S3Client: vi.fn(() => ({ - send: vi.fn(), - })), + S3Client: class MockS3Client { + send = vi.fn(); + }, DeleteObjectCommand: vi.fn(), GetObjectCommand: vi.fn(), + HeadObjectCommand: vi.fn(), + CopyObjectCommand: vi.fn(), + PutObjectCommand: vi.fn(), ObjectCannedACL: {}, })); diff --git a/server/utils/startup.ts b/server/utils/startup.ts index 96b4a81d0e..0e771787f1 100644 --- a/server/utils/startup.ts +++ b/server/utils/startup.ts @@ -1,3 +1,5 @@ +import cluster from "node:cluster"; +import os from "node:os"; import { styleText } from "node:util"; import { isEmpty } from "es-toolkit/compat"; import { toError, errToString } from "@shared/utils/error"; @@ -10,6 +12,65 @@ import { getArg } from "./args"; import { MutexLock } from "./MutexLock"; import { Minute } from "@shared/utils/time"; +/** + * Applies a default V8 heap limit to forked service processes when the process + * is running under a memory constraint (e.g. a container limit) and no limit + * was configured explicitly. Without this, V8 sizes its heap from the host's + * total memory, so several forked processes can together commit far more + * memory than the container allows and are eventually OOM-killed. + * + * The limit can be overridden by setting `--max-old-space-size` in + * `NODE_OPTIONS` or on the command line. + * + * @param processCount the number of service processes that will be forked. + */ +export function configureChildHeapLimit(processCount: number) { + if (cluster.isWorker) { + return; + } + + const nodeOptions = process.env.NODE_OPTIONS ?? ""; + const isConfigured = + nodeOptions.includes("--max-old-space-size") || + process.execArgv.some((arg) => arg.startsWith("--max-old-space-size")); + if (isConfigured) { + return; + } + + // constrainedMemory() reports 0/undefined when unknown, and an effectively + // unlimited sentinel (2^64) when no cgroup limit is set — only apply a + // default when a real constraint below the host's total memory exists. + const constrainedMemory = process.constrainedMemory(); + if (!constrainedMemory || constrainedMemory >= os.totalmem()) { + return; + } + + // Budget ~80% of the constraint for V8 heaps across all forked processes, + // leaving headroom for the master process and non-heap memory such as + // compiled code, buffers, and native allocations. + const totalMB = constrainedMemory / 1024 / 1024; + const budgetMB = Math.floor((totalMB * 0.8) / Math.max(1, processCount)); + + // Below 256MB per process the server cannot operate reliably, so the floor + // is applied even though the combined heaps may then exceed the budget. + const heapMB = Math.max(256, budgetMB); + if (heapMB > budgetMB) { + Logger.warn( + `The available memory of ${Math.round(totalMB)}MB is low for ${processCount} service process(es); reduce process count or increase available memory` + ); + } + + // Forked processes parse NODE_OPTIONS on startup, so this applies to every + // service process without affecting the already-running master. + process.env.NODE_OPTIONS = + `${nodeOptions} --max-old-space-size=${heapMB}`.trim(); + + Logger.info( + "lifecycle", + `Memory constraint of ${Math.round(totalMB)}MB detected, defaulting each service process to a ${heapMB}MB heap limit` + ); +} + /** * Checks for pending database migrations on startup and runs them, unless the * --no-migrate flag was passed in which case the process exits with an error. diff --git a/server/utils/zod.ts b/server/utils/zod.ts index 04d9b9cb11..ae7e152462 100644 --- a/server/utils/zod.ts +++ b/server/utils/zod.ts @@ -1,6 +1,6 @@ import emojiRegex from "emoji-regex"; import { z } from "zod"; -import { IconLibrary } from "@shared/utils/IconLibrary"; +import { iconNames } from "@shared/utils/IconNames"; import { UrlHelper } from "@shared/utils/UrlHelper"; /** @@ -35,11 +35,7 @@ export const zodIdType = () => * @returns a zod schema for icons. */ export const zodIconType = () => - z.union([ - z.string().regex(emojiRegex()), - zodEnumFromObjectKeys(IconLibrary.mapping), - z.uuid(), - ]); + z.union([z.string().regex(emojiRegex()), z.enum(iconNames), z.uuid()]); /** * Returns a zod schema that validates an emoji value, either an emoji diff --git a/shared/editor/commands/insertFiles.ts b/shared/editor/commands/insertFiles.ts index 60382d631a..354d44db33 100644 --- a/shared/editor/commands/insertFiles.ts +++ b/shared/editor/commands/insertFiles.ts @@ -1,4 +1,3 @@ -import * as Sentry from "@sentry/react"; import { t } from "i18next"; import { v4 as uuidv4 } from "uuid"; import type { EditorView } from "prosemirror-view"; @@ -226,7 +225,13 @@ const insertFiles = async function ( } }) .catch((error) => { - Sentry.captureException(error); + // Imported dynamically so the Sentry SDK stays out of the module graph + // until an upload actually fails, keeping it off server-side startup. + void import("@sentry/react") + .then((Sentry) => Sentry.captureException(error)) + .catch(() => { + // Reporting is best-effort; the error is logged below regardless. + }); // oxlint-disable-next-line no-console console.error(error); diff --git a/shared/editor/marks/Highlight.ts b/shared/editor/marks/Highlight.ts index a96be19f78..46d9199792 100644 --- a/shared/editor/marks/Highlight.ts +++ b/shared/editor/marks/Highlight.ts @@ -1,11 +1,10 @@ -import { isHexColor } from "class-validator"; import { parseToRgb, rgba } from "polished"; import type { MarkSpec, MarkType } from "prosemirror-model"; import { toggleMark } from "../commands/toggleMark"; import { markInputRuleForPattern } from "../lib/markInputRule"; import markRule from "../rules/mark"; import Mark from "./Mark"; -import { presetColors, hexToRgba } from "@shared/utils/color"; +import { presetColors, hexToRgba, validateColorHex } from "@shared/utils/color"; export default class Highlight extends Mark { /** The default opacity of the highlight */ @@ -72,7 +71,7 @@ export default class Highlight extends Mark { const color = dom.getAttribute("data-color") || ""; return { - color: isHexColor(color) ? color : null, + color: validateColorHex(color) ? color : null, }; }, }, diff --git a/shared/utils/IconLibrary.test.ts b/shared/utils/IconLibrary.test.ts new file mode 100644 index 0000000000..7f8e518b88 --- /dev/null +++ b/shared/utils/IconLibrary.test.ts @@ -0,0 +1,8 @@ +import { IconLibrary } from "./IconLibrary"; +import { iconNames } from "./IconNames"; + +describe("IconNames", () => { + it("stays in sync with IconLibrary.mapping", () => { + expect(Object.keys(IconLibrary.mapping)).toEqual([...iconNames]); + }); +}); diff --git a/shared/utils/IconNames.ts b/shared/utils/IconNames.ts new file mode 100644 index 0000000000..29ef514730 --- /dev/null +++ b/shared/utils/IconNames.ts @@ -0,0 +1,182 @@ +/** + * The names of all icons available to end users in the app, in the same order + * as `IconLibrary.mapping`. Kept as a standalone module so that code which + * only needs to validate icon names (e.g. server-side schemas) can avoid + * importing the icon components and their heavy dependency tree. + * + * A unit test in `IconLibrary.test.ts` ensures this list stays in sync with + * the mapping. + */ +export const iconNames = [ + "academicCap", + "bicycle", + "beaker", + "buildingBlocks", + "bookmark", + "browser", + "collection", + "coins", + "camera", + "carrot", + "clock", + "cloud", + "code", + "database", + "done", + "email", + "eye", + "feedback", + "flame", + "graph", + "globe", + "hashtag", + "info", + "icecream", + "image", + "internet", + "leaf", + "library", + "lightbulb", + "lightning", + "letter", + "math", + "moon", + "notepad", + "padlock", + "palette", + "pencil", + "plane", + "promote", + "ramen", + "question", + "server", + "sun", + "shapes", + "sport", + "smiley", + "target", + "team", + "terminal", + "thumbsup", + "truck", + "tools", + "vehicle", + "warning", + "bag-shopping", + "book", + "brush", + "cake-candles", + "cat", + "clapperboard", + "compact-disc", + "cookie-bite", + "crow", + "crown", + "cube", + "dna", + "dog", + "dollar-sign", + "display", + "droplet", + "face-dizzy", + "face-grin-stars", + "face-laugh", + "face-meh", + "face-smile-beam", + "face-smile-wink", + "face-surprise", + "feather", + "fish", + "folder-closed", + "flask-vial", + "gamepad", + "gauge", + "gem", + "gift", + "hammer", + "hands-clapping", + "heart", + "industry", + "kit-medical", + "laptop", + "laptop-code", + "magnet", + "map", + "microchip", + "mountain-sun", + "mug-hot", + "network-wired", + "newspaper", + "paint-roller", + "passport", + "paw", + "pen-ruler", + "peso-sign", + "phone-volume", + "pizza-slice", + "prescription", + "puzzle-piece", + "rainbow", + "record-vinyl", + "road", + "robot", + "rocket", + "sailboat", + "scissors", + "seedling", + "shield", + "shirt", + "shop", + "snowflake", + "socks", + "solar-panel", + "spa", + "star-and-crescent", + "star-of-life", + "sterling-sign", + "swatchbook", + "tent", + "tooth", + "tower-cell", + "tractor", + "train", + "tree", + "trophy", + "umbrella", + "umbrella-beach", + "universal-access", + "user-graduate", + "utensils", + "vault", + "wand-sparkles", + "web-awesome", + "wheelchair-move", + "worm", + "yen-sign", + "apple", + "windows", + "android", + "square-js", + "python", + "x-twitter", + "bluesky", + "github", + "gitlab", + "discord", + "docker", + "codepen", + "dropbox", + "paypal", + "shopify", + "swift", + "slack", + "circle", + "square", + "pentagon", + "hexagon", + "diamond", + "spiral", +] as const; + +/** The name of an icon from the icon library. */ +export type IconName = (typeof iconNames)[number]; diff --git a/shared/utils/date.ts b/shared/utils/date.ts index afd1f2db58..19d4e875c9 100644 --- a/shared/utils/date.ts +++ b/shared/utils/date.ts @@ -16,32 +16,33 @@ import { isValid, parse, } from "date-fns"; -import { - ca, - cs, - de, - enGB, - enUS, - es, - faIR, - fr, - he, - hu, - it, - ja, - ko, - nb, - nl, - ptBR, - pt, - pl, - sv, - tr, - vi, - uk, - zhCN, - zhTW, -} from "date-fns/locale"; +// Locales are imported from their individual modules rather than the +// "date-fns/locale" index, which would load every locale date-fns ships +// (~90) into memory rather than only those supported by the app. +import { ca } from "date-fns/locale/ca"; +import { cs } from "date-fns/locale/cs"; +import { de } from "date-fns/locale/de"; +import { enGB } from "date-fns/locale/en-GB"; +import { enUS } from "date-fns/locale/en-US"; +import { es } from "date-fns/locale/es"; +import { faIR } from "date-fns/locale/fa-IR"; +import { fr } from "date-fns/locale/fr"; +import { he } from "date-fns/locale/he"; +import { hu } from "date-fns/locale/hu"; +import { it } from "date-fns/locale/it"; +import { ja } from "date-fns/locale/ja"; +import { ko } from "date-fns/locale/ko"; +import { nb } from "date-fns/locale/nb"; +import { nl } from "date-fns/locale/nl"; +import { ptBR } from "date-fns/locale/pt-BR"; +import { pt } from "date-fns/locale/pt"; +import { pl } from "date-fns/locale/pl"; +import { sv } from "date-fns/locale/sv"; +import { tr } from "date-fns/locale/tr"; +import { vi } from "date-fns/locale/vi"; +import { uk } from "date-fns/locale/uk"; +import { zhCN } from "date-fns/locale/zh-CN"; +import { zhTW } from "date-fns/locale/zh-TW"; import type { DateFilter } from "../types"; import { isBrowser } from "./browser"; diff --git a/shared/utils/icon.ts b/shared/utils/icon.ts index d325e27ddf..b962f56272 100644 --- a/shared/utils/icon.ts +++ b/shared/utils/icon.ts @@ -1,8 +1,8 @@ import { isUUID } from "validator"; import { IconType } from "../types"; -import { IconLibrary } from "./IconLibrary"; +import { iconNames } from "./IconNames"; -const outlineIconNames = new Set(Object.keys(IconLibrary.mapping)); +const outlineIconNames = new Set(iconNames); export const determineIconType = ( icon?: string | null