From 24f95cadc3e3cde54566f8811d568a06a8af337a Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 18 Jul 2026 18:34:32 -0400 Subject: [PATCH] perf: Reduce server memory usage and add container memory best practices (#13042) * Reduce server memory usage and add container memory best practices Profiling a full-service boot showed ~300MB RSS per service process, much of it client-only dependencies pulled into the server module graph, and no heap limits applied in containers: - Import date-fns locales individually rather than via the locale index, which loaded all ~90 locales into every process - Load @sentry/react dynamically in insertFiles so the browser Sentry SDK stays out of the server-side editor graph - Replace class-validator isHexColor with the existing validateColorHex in the Highlight mark - Extract icon names into a standalone IconNames module so server-side schema validation no longer imports Font Awesome icon packs, with a test to keep it in sync with IconLibrary - Require S3Storage lazily so the AWS SDK and native CRT binding are only loaded when S3 file storage is configured - Default each forked service process to a V8 heap limit derived from the cgroup memory constraint, preventing several processes from together committing more memory than the container allows - Cap glibc malloc arenas in the Docker image to reduce resident memory fragmentation in multi-threaded processes Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016YMX5ReGHyxrR4ZE5ZbaXa * Require both file storage backends lazily for consistency Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016YMX5ReGHyxrR4ZE5ZbaXa * Load AWS SDK via dynamic imports compatible with test environment Requiring the storage TypeScript modules lazily broke under Vitest, which cannot resolve require() of source files and does not apply vi.mock to bare requires. Move the laziness into S3Storage instead: the module is imported statically, and the AWS SDK packages are loaded with dynamic imports on first use, including deferred creation of the S3 client. Also address review feedback: handle rejection of the dynamic Sentry import, and warn when the minimum per-process heap limit exceeds the memory budget of the container. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016YMX5ReGHyxrR4ZE5ZbaXa * Consolidate S3 SDK loading into a single lazy accessor The commands are loaded together with the client in getS3() rather than as separate dynamic imports per method. The global S3Client test mock is now a class, as a mock function is not constructable when reached through a dynamic import. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016YMX5ReGHyxrR4ZE5ZbaXa --------- Co-authored-by: Claude --- Dockerfile | 5 + server/index.ts | 17 ++- server/storage/files/S3Storage.test.ts | 6 +- server/storage/files/S3Storage.ts | 112 ++++++++------- server/storage/files/index.ts | 6 +- server/test/setup.ts | 13 +- server/utils/startup.ts | 61 +++++++++ server/utils/zod.ts | 8 +- shared/editor/commands/insertFiles.ts | 9 +- shared/editor/marks/Highlight.ts | 5 +- shared/utils/IconLibrary.test.ts | 8 ++ shared/utils/IconNames.ts | 182 +++++++++++++++++++++++++ shared/utils/date.ts | 53 +++---- shared/utils/icon.ts | 4 +- 14 files changed, 394 insertions(+), 95 deletions(-) create mode 100644 shared/utils/IconLibrary.test.ts create mode 100644 shared/utils/IconNames.ts 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